--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit c2a183d688da793e20dfe6876d2c12add7b6ced2
Parents : db8a585
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T00:57:53-05:00
feat: map overlays, identity restore, settings, conversations, and i18n updates
Changes
125 files changed, 8788 insertions(+), 907 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d33c1100..2c899cc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
+- **Map / remote overlays**: Import KMZ/KML/GeoJSON overlays from NomadNet ``hash:/file/...`` links and sparse RNGit ``rns://hash/group/repo`` fetches (specific paths at branch/tag/commit). Managed overlay sources with backend cache, refresh/autorefresh, retries with backoff, path requests, atomic writes, SHA256 skip, re-export (GeoJSON/KML/KMZ), and Settings → Map limits/timeouts. APIs under ``/api/v1/map/overlays*``.
- **Plugins / security**: MeshChatX plugin signing with RSG verification (`plugin_rsg.py`), canonical ZIP/dir payloads, trusted publishers with digest tamper detection, post-install integrity hashing, and heuristic security findings. Invalid signatures hard-block install. Preview/install expose signature and findings. Trusted publisher APIs under `/api/v1/plugins/trusted-publishers`.
- **Plugins / WASM bundles**: Single-file `.wasm` install with embedded `meshchatx.plugin` / `meshchatx.files` / `meshchatx.signature` custom sections (`plugin_wasm_bundle.py`). Preview/install accept `.wasm` as well as ZIP. Signing CLI `scripts/sign-plugin.py` for dir/zip/wasm/py.
- **Plugins / Python backend**: ZIP/WASM-unpacked `backend.type: "python"` runtime (`plugin_python_runtime.py`) with permission-checked host, hooks/invoke, and Python badge in settings.
@@ -17,32 +18,37 @@ All notable changes to this project will be documented in this file.
- **Plugins**: Contribution-point registries for sidebar navigation, tools catalog, command palette, settings sections, and typed WebSocket event dispatch. Core UI surfaces are data-driven instead of hardcoded per component.
- **Plugins**: **Bundled i18n**. Plugins ship `locales/{locale}.json` in the package. The host loads labels from plugin assets so third-party plugins do not need changes to MeshChatX main locale files.
- **Plugins**: Declarative UI slot vocabulary in `PluginSlotRenderer` / `PluginSlotNode` for sections, action button rows, badges, card lists, grid rows, and text variants on plugin tool pages.
-- **Plugins**: Bundled **Mesh Observatory** example plugin with live announce feed, searchable path table with hop/interface/state columns, and announce-driven refresh.
+- **Plugins**: Bundled **Bug Reports** plugin (`com.meshchatx.mcx-bugs`) with sender and collector modes over aspect ``mcx-bugs-v1``, selectable log redaction, and Tools/nav contribution. Host capabilities ``debugLog.read`` and ``bugReport.*`` plus checkbox/textarea plugin UI slots.
- **Plugins**: Security guardrails in `plugin_guard.py` for ZIP size/magic validation, zip-slip protection, asset path normalization, WASM size/magic checks, invoke payload limits, and an error budget that auto-disables misbehaving plugins after repeated failures.
- **Plugins**: `POST /api/v1/plugins/{id}/report-failure` for frontend worker crash reporting. Kill-switch broadcasts over WebSocket when a plugin is auto-disabled.
- **Dependencies**: Added **wasmtime** for backend WASM plugin execution.
- **Plugins**: `--disable-plugins` CLI flag and `MESHCHAT_DISABLE_PLUGINS` environment variable to disable the plugin system entirely at runtime.
-- **RNS Link API**: Generic WebSocket Link transport (`rns.link.open` / `identify` / `request` / `send` / `close` plus `rns.link.event` broadcasts) so external apps can use MeshChatX as an RNS transport. Based on contributions from [attermann/api_extensions](https://github.com/attermann/MeshChatX/tree/api_extensions). Auth-gated when password auth is enabled; in-flight open/request tasks cancel on client disconnect.
+- **RNS Link API**: Generic WebSocket Link transport (`rns.link.open` / `identify` / `request` / `send` / `close` plus `rns.link.event` broadcasts) so external apps can use MeshChatX as an RNS transport. Based on contributions from [attermann/api_extensions](https://github.com/attermann/MeshChatX/tree/api_extensions). Auth-gated when password auth is enabled. In-flight open/request tasks cancel on client disconnect.
- **Plugins / RNS Link**: Manager capabilities `rnsLink.open`, `rnsLink.identify`, `rnsLink.request`, `rnsLink.send`, `rnsLink.close` and hook `rns.link.event` for embedding transport-node tools (for example microReticulum management) as MeshChatX plugins.
- **Tests / RNS Link**: Unit, edge-case, Hypothesis fuzz/property, plugin capability, WS auth, smoke, behavior-contract, and self-check coverage for the generic Link API (`websocket_rns_link_good` probe).
-- **Plugins / install consent**: ZIP install previews via `POST /api/v1/plugins/preview`, then a confirmation dialog listing requested permissions and scanned/declared external HTTP endpoints. Users can deny individual grants; runtime enforces declared+granted hooks/managers/storage/`network:fetch`.
+- **Plugins / install consent**: ZIP install previews via `POST /api/v1/plugins/preview`, then a confirmation dialog listing requested permissions and scanned/declared external HTTP endpoints. Users can deny individual grants. Runtime enforces declared+granted hooks/managers/storage/`network:fetch`.
- **Vendored LXMFy**: Refreshed `vendor/lxmfy` to upstream **1.6.5** (`d92cfe0`) with Landlock LSM sandbox for bot processes and external cogs, propagation-node init fix, cog permission fix, and dependency alignment with RNS 1.3.5+ / LXMF 1.0.1+.
- **Mutation testing**: Backend uses **mutmut** (`task test:mutation:backend`). Frontend uses in-repo **MeshMut** (`task test:mutation:frontend`) with regex-based mutators and Vitest for pure JS modules. Optional `mutation.yml` workflow for manual or scheduled runs.
### Changed
-- **CI benchmarks**: Suite runs **3 times** and reports **median of medians** with MAD/CV in the JSON extra field. A smart gate replaces flat ratio alerts: noise floor (0.5 ms), minimum absolute delta (1.5 ms), and adaptive ratio thresholds that widen for tiny/noisy baselines. The previous ``Trim Announces`` 2.57x false positive (0.177→0.455 ms) is ignored; that bench now re-seeds before each sample so it measures a real DELETE. ``actions/cache`` bumped to **v5** (Node 24).
-- **Startup**: HTTP server binds before Reticulum/identity setup. RNS and identity context initialize on a background thread while Electron can open the UI shell; `/api/v1/status` reports `starting`/`ok` with `stage` and `network_ready`, and the Vue boot splash waits until the network stack is ready. CLI one-shots (`--self-check`, backup/restore, etc.) still initialize synchronously. Off-main-thread RNS construction skips ``signal.signal`` registration (Python restriction) and reinstalls SIGINT/SIGTERM handlers on the main loop once ready.
-- **Relay Chat**: Collapsed sidebar add control is a plain plus (no dashed border). Available Rooms can collapse and the state is remembered in localStorage. Discover hub list spacing is tighter and the search placeholder shows the heard hub count. Hub create/settings support announce interval (slider + minutes input); hosted hub cards show human-readable uptime, label connected peers as users, and expose settings to rename or change/disable announces. Consecutive join/leave/connection system lines auto-collapse into a summary that can be expanded. Auto-rejoin after reconnect records ``You rejoined #room``; link drops/manual disconnect/reconnect also write ``Connection lost`` / ``Disconnected from hub`` / ``Reconnected to hub`` into joined room timelines.
+- **CI benchmarks**: Suite runs **3 times** and reports **median of medians** with MAD/CV in the JSON extra field. A smart gate replaces flat ratio alerts: noise floor (0.5 ms), minimum absolute delta (1.5 ms), and adaptive ratio thresholds that widen for tiny/noisy baselines. The previous ``Trim Announces`` 2.57x false positive (0.177→0.455 ms) is ignored. That bench now re-seeds before each sample so it measures a real DELETE. ``actions/cache`` bumped to **v5** (Node 24).
+- **Startup**: HTTP server binds before Reticulum/identity setup. RNS and identity context initialize on a background thread while Electron can open the UI shell. `/api/v1/status` reports `starting`/`ok` with `stage` and `network_ready`, and the Vue boot splash waits until the network stack is ready. CLI one-shots (`--self-check`, backup/restore, etc.) still initialize synchronously. Off-main-thread RNS construction skips ``signal.signal`` registration (Python restriction) and reinstalls SIGINT/SIGTERM handlers on the main loop once ready.
+- **Relay Chat**: Collapsed sidebar add control is a plain plus (no dashed border). Available Rooms can collapse and the state is remembered in localStorage. Discover hub list spacing is tighter and the search placeholder shows the heard hub count. Hub create/settings support announce interval (slider + minutes input). Hosted hub cards show human-readable uptime, label connected peers as users, and expose settings to rename or change/disable announces. Consecutive join/leave/connection system lines auto-collapse into a summary that can be expanded. Auto-rejoin after reconnect records ``You rejoined #room``. Link drops/manual disconnect/reconnect also write ``Connection lost`` / ``Disconnected from hub`` / ``Reconnected to hub`` into joined room timelines.
+
+- **Dependencies**: pnpm overrides bump transitive **minimist** to **>=1.2.8** and **fast-uri** to **>=3.1.2** (prototype pollution / URI normalization advisories via electron-builder tooling).
+- **Settings**: Settings section search keywords moved into `settingsSectionRegistry` for reuse by plugins and core sections.
+- **App shell**: WebSocket handling in `App.vue` migrated to typed per-event handlers via `wsEventRegistry`.
+- **Locales**: Main app locale files retain only **Settings → Plugins** UI strings. Per-plugin copy lives in each plugin bundle.
### Fixed
- **Settings**: RPC key is hidden by default (star/bullet mask) and reveals on click/tap. Failed self-test rows expand to show the failure reason. Plugins moved to their own Settings tab. Notification sound enable toggle no longer crowds the description. Community Interfaces settings include a refresh control that fetches from ``directory.rns.recipes`` submitted + discovered online listings (updated default URLs).
- **Android / startup**: Loading screen no longer shows attempt counters like ``(12/120)``. Copy uses short friendly phases, splash uses a full uncropped logo, and the adaptive launcher foreground is padded so corners are not clipped by the circular/squircle mask.
-- **Android**: Nightly/APK boot no longer crashes with ``ModuleNotFoundError: No module named 'lxmfy'``. Gradle syncs vendored ``vendor/lxmfy/lxmfy`` into Chaquopy ``src/main/python/lxmfy`` (desktop already got it via setuptools; Android pip never installed it).
+- **Android**: Nightly/APK boot no longer crashes with ``ModuleNotFoundError: No module named 'lxmfy'``. Gradle syncs vendored ``vendor/lxmfy/lxmfy`` into Chaquopy ``src/main/python/lxmfy`` (desktop already got it via setuptools, Android pip never installed it).
- **Android**: Backend boot no longer exits with ``SystemExit: 1`` when ``fcntl.flock`` is unimplemented (common on Android). ``StorageLock`` falls back to a PID soft lock, and the Chaquopy wrapper clears a stale ``.meshchatx.lock`` before ``main()``.
- **CI / Android**: Nightly and ``workflow_dispatch`` emulator smoke (``.github/workflows/android-emulator-smoke.yml``) builds an x86_64 debug APK, installs it on an AVD, launches ``MainActivity``, and requires on-device ``/api/v1/status`` to return ok (catches Chaquopy boot failures that ``assembleDebug`` alone misses).
-- **Network visualiser**: Physics and canvas draw cost brought below upstream MeshChat for large meshes — disabled Barnes–Hut ``avoidOverlap``, matched upstream gravity, dropped per-edge arrows/dashes (direct vs multi-hop still distinguished by color/width), hide edges while zooming, larger adaptive build chunks with sync path for small graphs, debounced search rebuilds, cheaper LOD updates, and removed toolbar/legend backdrop-blur compositing over the canvas.
+- **Network visualiser**: Physics and canvas draw cost brought below upstream MeshChat for large meshes - disabled Barnes-Hut ``avoidOverlap``, matched upstream gravity, dropped per-edge arrows/dashes (direct vs multi-hop still distinguished by color/width), hide edges while zooming, larger adaptive build chunks with sync path for small graphs, debounced search rebuilds, cheaper LOD updates, and removed toolbar/legend backdrop-blur compositing over the canvas.
- **Relay Chat**: Message list no longer stacks or duplicates text. Keys prefer message ``seq``, websocket/history loads dedupe, and room loads merge live websocket arrivals instead of wiping them.
- **Nomad Network / favourites**: Favourite names no longer become **Unknown Node** when a path or announce is missing. Resolution falls back to the stored favourite name (and announce cache), and unknown/localized placeholders no longer overwrite real names on re-add or bulk-add.
- **Nomad Network / sections**: Moving favourites into custom named sections now persists across reload and identity switches (DB-backed). Layout reconciliation no longer wipes storage while favourites are still loading.
@@ -54,7 +60,7 @@ All notable changes to this project will be documented in this file.
- **CI / nightly**: Daily ``nightly-YYYY.MM.DD-<sha>`` tags from ``dev`` now explicitly ``workflow_dispatch`` ``build-release.yml`` after tagging so full release assets are produced.
- **CI / nightly**: Release upload creates nightlies and previews as **drafts**, attaches all assets, then publishes as prereleases so immutable-release repos can still receive binaries.
- **Plugins**: Plugin worker `postRequest` Promise wrapper, plugin locale loading at boot, cached UI on page open, and slot renderer recursion for nested column/list/row children.
-- **Plugins**: Mesh Observatory layout with spaced action buttons, section cards, truncated interface names, and state badges instead of squashed single-line rows.
+- **Plugins**: Removed the Mesh Observatory example plugin.
- **RNode / Android**: Hardened `rnode_support` startup guards. Desktop TCP RNode no longer incorrectly requires pyserial. Desktop BLE now checks for bleak instead of pyserial. Whitespace-only Bluetooth ports classify correctly. Invalid `tcp:///` hosts are no longer backfilled. `RNodeIPInterface` entries get `tcp_host` backfill on Android. RNodeMulti sibling sub-interfaces with invalid TX power are detected and disabled. Txpower guard honors both `enabled` and `interface_enabled` keys.
- **RNode / desktop**: Added **bleak** as a core dependency and a desktop startup guard that disables unsupported RNode interfaces before Reticulum starts, fixing backend crashes when RNode over BLE is configured on Windows without bleak installed ([#46](https://github.com/Quad4-Software/MeshChatX/issues/46)).
- **CI / tests**: Dependency contract test for bleak, startup integration test for the desktop RNode guard, and cx_Freeze build verification that bleak is bundled.
@@ -63,13 +69,6 @@ All notable changes to this project will be documented in this file.
- **Settings**: Tabbed settings navigation with section-to-tab mapping, search across tabs, and `SettingsNav` component.
- **Settings**: Plugin settings search no longer treats `index.mu` / `index.html` literals as missing i18n keys.
-### Changed
-
-- **Dependencies**: pnpm overrides bump transitive **minimist** to **>=1.2.8** and **fast-uri** to **>=3.1.2** (prototype pollution / URI normalization advisories via electron-builder tooling).
-- **Settings**: Settings section search keywords moved into `settingsSectionRegistry` for reuse by plugins and core sections.
-- **App shell**: WebSocket handling in `App.vue` migrated to typed per-event handlers via `wsEventRegistry`.
-- **Locales**: Main app locale files retain only **Settings → Plugins** UI strings. Per-plugin copy lives in each plugin bundle.
-
### Tests
- **Startup**: Deferred RNS/identity init covered by unit, middleware (503 vs status/auth/csrf), concurrent status reads, failure/idempotency edge cases, Hypothesis fuzz of status payloads, Vue ``networkStartupWait`` polls, and Electron ``loadingStatusProbe`` accept/reject rules for ``starting``/``ok``/``failed``.
diff --git a/README.md b/README.md
index 1f08bd0d..cce2b9be 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ rngit: `git clone rns://06a54b505bb67b25ef3f8097e8001edc/public/MeshChatX`
- Uses LXST for calls
- Integrates [RRC](https://rrc.kc1awv.net/0)
- Expanded tools
-- Map w/ MBTiles support
+- Map w/ MBTiles support, remote KMZ/KML/GeoJSON overlays (NomadNet `/file/` and RNGit sparse fetch)
- Panes and Tabs
- Replaced Peewee ORM with raw SQL.
- Replaced Axios with native fetch.
diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py
index ee9fef11..901cc9b8 100644
--- a/android/app/src/main/python/meshchat_wrapper.py
+++ b/android/app/src/main/python/meshchat_wrapper.py
@@ -18,14 +18,33 @@ def _ensure_android_reticulum_config(reticulum_config_dir):
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as existing_file:
content = existing_file.read()
+ changed = False
if "share_instance = Yes" in content:
content = content.replace("share_instance = Yes", "share_instance = No")
+ changed = True
+ if "panic_on_interface_error" not in content:
+ if "[reticulum]" in content:
+ content = content.replace(
+ "[reticulum]",
+ "[reticulum]\n panic_on_interface_error = No",
+ 1,
+ )
+ else:
+ content = "[reticulum]\n panic_on_interface_error = No\n\n" + content
+ changed = True
+ if changed:
with open(config_path, "w", encoding="utf-8") as config_file:
config_file.write(content)
return
with open(config_path, "w", encoding="utf-8") as config_file:
- config_file.write("[reticulum]\n share_instance = No\n\n[interfaces]\n")
+ config_file.write(
+ "[reticulum]\n"
+ " share_instance = No\n"
+ " panic_on_interface_error = No\n"
+ "\n"
+ "[interfaces]\n"
+ )
def _patch_asyncio_signal_handlers_for_android():
@@ -89,6 +108,19 @@ def _clear_stale_storage_lock(storage_dir):
print(f"meshchat_wrapper: could not clear storage lock: {exc}")
+def _patch_rns_panic_for_android():
+ """Stop RNS.panic/os._exit from killing the whole Android process."""
+ try:
+ from meshchatx.src.backend.rns_startup_recovery import (
+ install_rns_panic_containment,
+ )
+
+ return install_rns_panic_containment()
+ except Exception as exc:
+ print(f"meshchat_wrapper: RNS panic containment skipped: {exc}")
+ return False
+
+
def start_server(port=8000, app_files_dir=None):
global _server_loop_active
with _server_loop_lock:
@@ -121,8 +153,12 @@ def start_server(port=8000, app_files_dir=None):
signal.signal = _safe_signal
asyncio_signal_patch = _patch_asyncio_signal_handlers_for_android()
aiohttp_run_app_patch = _patch_aiohttp_run_app_for_android()
+ _patch_rns_panic_for_android()
try:
- from meshchatx.android_codec2 import ensure_codec2_native_library, probe_pycodec2
+ from meshchatx.android_codec2 import (
+ ensure_codec2_native_library,
+ probe_pycodec2,
+ )
ensure_codec2_native_library()
ok, err = probe_pycodec2()
diff --git a/docs/en/architecture.md b/docs/en/architecture.md
index be4c7677..18aed096 100644
--- a/docs/en/architecture.md
+++ b/docs/en/architecture.md
@@ -10,6 +10,8 @@ MeshChatX is a heavily extended fork of Reticulum MeshChat. The goals below shap
- Keep the Python backend and Vue frontend independently testable.
- Run in constrained environments with predictable SQLite behaviour.
+Mesh features should follow Reticulum’s post-IP design patterns (portable identity hashes, announces, store-and-forward, transport-agnostic APIs, scarce payloads). Agent and contributor gates live in `docs/agents/conventions/reticulum-zen.md` and `docs/agents/skills/reticulum-design-gates/SKILL.md`, derived from the [Zen of Reticulum](https://reticulum.network/manual/zen.html).
+
## Process overview
One Python process owns the web server, Reticulum stack, and all per-identity managers. The Vue frontend is static assets served from `meshchatx/public/` after a Vite build.
@@ -138,7 +140,7 @@ Practical extension paths today:
- Database schema changes through migrations
- Generic RNS Link transport over WebSocket (`rns.link.*`) for external consoles and plugins (see **RNS Link API**)
-Granted plugin manager capabilities include `destinationPath.read` and `rnsLink.open` / `identify` / `request` / `send` / `close`. Hooks include `announce.received` and `rns.link.event`. Storage (`storage:isolated`) and outbound HTTP (`network:fetch`) are also grantable; install preview scans plugin files for external URLs and stores the user-selected grant subset.
+Granted plugin manager capabilities include `destinationPath.read`, `debugLog.read`, `bugReport.*`, and `rnsLink.open` / `identify` / `request` / `send` / `close`. Hooks include `announce.received` and `rns.link.event`. Storage (`storage:isolated`) and outbound HTTP (`network:fetch`) are also grantable; install preview scans plugin files for external URLs and stores the user-selected grant subset.
When adding features, prefer identity-scoped state, explicit migrations, endpoint tests, and narrowly declared plugin permissions.
diff --git a/docs/en/rns-link-api.md b/docs/en/rns-link-api.md
index f9e6e3ee..64376caf 100644
--- a/docs/en/rns-link-api.md
+++ b/docs/en/rns-link-api.md
@@ -60,9 +60,9 @@ Example manifest fragment:
## Implementation
-- `meshchatx/src/backend/rns_link_manager.py` — link cache, open/identify/request/send/close
-- `meshchatx/meshchat.py` — WebSocket dispatch and per-client task tracking
-- `meshchatx/src/backend/plugin_manager.py` — capability wrappers and hook fan-out
+- `meshchatx/src/backend/rns_link_manager.py` - link cache, open/identify/request/send/close
+- `meshchatx/meshchat.py` - WebSocket dispatch and per-client task tracking
+- `meshchatx/src/backend/plugin_manager.py` - capability wrappers and hook fan-out
## Related
diff --git a/docs/en/tools.md b/docs/en/tools.md
index 783a50e3..e16c18a5 100644
--- a/docs/en/tools.md
+++ b/docs/en/tools.md
@@ -77,7 +77,7 @@ When `rrc_enabled` is on, you can run a local RRC hub from relay chat server set
## Plugins
-Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Mesh Observatory** (`com.meshchatx.mesh-observatory`) for live announce feeds and path tables.
+Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Bug Reports** (`com.meshchatx.mcx-bugs`) for sending redacted debug logs to an `mcx-bugs-v1` collector (or running a collector yourself).
Plugins are capability-gated, not fully open-ended: they cannot rewrite core MeshChatX. Supported packaged runtimes are **frontend JS** (Worker), optional **backend WASM** (wasmtime), and optional **backend Python** (`backend.type: "python"`). Install sources include ZIP archives and single-file **WASM bundles** with embedded `plugin.json` / files / optional RSG signature.
diff --git a/meshchatx/src/backend/auto_propagation_manager.py b/meshchatx/src/backend/auto_propagation_manager.py
index f1c02c36..37055bf5 100644
--- a/meshchatx/src/backend/auto_propagation_manager.py
+++ b/meshchatx/src/backend/auto_propagation_manager.py
@@ -234,18 +234,7 @@ class AutoPropagationManager:
)
return
- # None of the candidates worked. If the previously-selected node is
- # still unreachable, clear it rather than restoring a broken node.
- if previous_hex:
- try:
- previous_dest = bytes.fromhex(previous_hex)
- if RNS.Transport.has_path(previous_dest):
- self.app.set_active_propagation_node(
- previous_hex, context=self.context
- )
- return
- except Exception:
- pass
- self.app.remove_active_propagation_node(context=self.context)
- else:
- self.app.remove_active_propagation_node(context=self.context)
+ # None of the candidates worked (including the previous node if it was
+ # probed). Clear the active node rather than restoring a sync-broken one
+ # just because a transport path still exists.
+ self.app.remove_active_propagation_node(context=self.context)
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 97e3d0f7..0043e37c 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -304,6 +304,56 @@ class ConfigManager:
"map_nominatim_api_url",
"https://nominatim.openstreetmap.org",
)
+ self.map_overlay_max_bytes = self.IntConfig(
+ self,
+ "map_overlay_max_bytes",
+ 8 * 1024 * 1024,
+ )
+ self.map_overlay_max_features = self.IntConfig(
+ self,
+ "map_overlay_max_features",
+ 50_000,
+ )
+ self.map_overlay_max_kmz_uncompressed_bytes = self.IntConfig(
+ self,
+ "map_overlay_max_kmz_uncompressed_bytes",
+ 16 * 1024 * 1024,
+ )
+ self.map_overlay_max_sources = self.IntConfig(
+ self,
+ "map_overlay_max_sources",
+ 64,
+ )
+ self.map_overlay_max_concurrent_jobs = self.IntConfig(
+ self,
+ "map_overlay_max_concurrent_jobs",
+ 2,
+ )
+ self.map_overlay_path_timeout_seconds = self.IntConfig(
+ self,
+ "map_overlay_path_timeout_seconds",
+ 30,
+ )
+ self.map_overlay_transfer_timeout_seconds = self.IntConfig(
+ self,
+ "map_overlay_transfer_timeout_seconds",
+ 120,
+ )
+ self.map_overlay_job_timeout_seconds = self.IntConfig(
+ self,
+ "map_overlay_job_timeout_seconds",
+ 300,
+ )
+ self.map_overlay_max_retries = self.IntConfig(
+ self,
+ "map_overlay_max_retries",
+ 3,
+ )
+ self.map_overlay_retry_delay_seconds = self.IntConfig(
+ self,
+ "map_overlay_retry_delay_seconds",
+ 2,
+ )
# telemetry config
self.telemetry_enabled = self.BoolConfig(self, "telemetry_enabled", False)
diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js b/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
deleted file mode 100644
index 0e8d6799..00000000
--- a/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
+++ /dev/null
@@ -1,231 +0,0 @@
-const MAX_ANNOUNCES = 80;
-
-/**
- * @param {{ t: (key: string) => string }} api
- * @param {string} key
- * @param {Record<string, string | number>} [params]
- */
-function formatLabel(api, key, params = {}) {
- let text = api.t(key);
- for (const [name, value] of Object.entries(params)) {
- text = text.replace(`{${name}}`, String(value));
- }
- return text;
-}
-
-function shortHash(hash) {
- if (!hash || hash.length < 12) {
- return hash || "—";
- }
- return `${hash.slice(0, 10)}…${hash.slice(-6)}`;
-}
-
-function shortInterface(name) {
- if (!name || typeof name !== "string") {
- return "—";
- }
- const match = name.match(/\[([^\]]+)\]/);
- if (match) {
- return match[1];
- }
- if (name.length > 36) {
- return `${name.slice(0, 18)}…${name.slice(-10)}`;
- }
- return name;
-}
-
-function hopLabel(api, hops) {
- if (hops == null) {
- return formatLabel(api, "hops_unknown");
- }
- if (hops === 1) {
- return formatLabel(api, "hops_one");
- }
- return formatLabel(api, "hops_many", { count: hops });
-}
-
-function stateNode(api, state) {
- let label = formatLabel(api, "state_unknown");
- let variant = "muted";
- if (state === 1) {
- label = formatLabel(api, "state_responsive");
- variant = "success";
- } else if (state === 2) {
- label = formatLabel(api, "state_unresponsive");
- variant = "danger";
- }
- return { type: "badge", label, variant };
-}
-
-/**
- * @param {{ t: (key: string) => string, invoke: Function, setUi: Function, onAction: Function, onEvent: Function, onRefresh: Function, getInputValue: Function }} api
- */
-export async function activate(api) {
- /** @type {Array<Record<string, string>>} */
- let announces = [];
- /** @type {{ paths: Array<Record<string, unknown>>, total: number, responsive: number, unresponsive: number }} */
- let pathData = { paths: [], total: 0, responsive: 0, unresponsive: 0 };
-
- async function refreshPaths() {
- const search = (api.getInputValue("path-search") || "").trim();
- pathData = await api.invoke("readPaths", {
- search: search || undefined,
- limit: 150,
- });
- }
-
- function render() {
- const announceFilter = (api.getInputValue("announce-filter") || "").trim().toLowerCase();
- const filteredAnnounces = announces.filter((entry) => {
- if (!announceFilter) {
- return true;
- }
- const haystack =
- `${entry.aspect || ""} ${entry.destination_hash || ""} ${entry.app_data || ""}`.toLowerCase();
- return haystack.includes(announceFilter);
- });
-
- api.setUi({
- type: "column",
- children: [
- {
- type: "text",
- variant: "title",
- value: formatLabel(api, "title"),
- },
- {
- type: "text",
- variant: "body",
- value: formatLabel(api, "description"),
- },
- {
- type: "actions",
- items: [
- {
- type: "button",
- id: "refresh",
- label: formatLabel(api, "refresh"),
- },
- ],
- },
- {
- type: "section",
- title: formatLabel(api, "announces_section"),
- description: formatLabel(api, "announce_stats", {
- shown: Math.min(filteredAnnounces.length, 40),
- total: announces.length,
- }),
- children: [
- {
- type: "input",
- id: "announce-filter",
- label: formatLabel(api, "filter"),
- placeholder: formatLabel(api, "filter_placeholder"),
- },
- {
- type: "actions",
- items: [
- {
- type: "button",
- id: "clear-announces",
- variant: "secondary",
- label: formatLabel(api, "clear_feed"),
- },
- ],
- },
- {
- type: "list",
- variant: "cards",
- emptyText: formatLabel(api, "no_announces"),
- items: filteredAnnounces.slice(0, 40).map((entry) => ({
- type: "row",
- variant: "announce-card",
- children: [
- { type: "text", variant: "mono", value: entry.receivedAt || "—" },
- { type: "text", variant: "stat", value: entry.aspect || "—" },
- { type: "text", variant: "mono", value: shortHash(entry.destination_hash) },
- {
- type: "text",
- variant: "caption",
- value: (entry.app_data || "").slice(0, 72) || "—",
- },
- ],
- })),
- },
- ],
- },
- {
- type: "section",
- title: formatLabel(api, "paths_section"),
- description: formatLabel(api, "path_stats", {
- total: pathData.total || 0,
- responsive: pathData.responsive || 0,
- unresponsive: pathData.unresponsive || 0,
- }),
- children: [
- {
- type: "input",
- id: "path-search",
- label: formatLabel(api, "path_search"),
- placeholder: formatLabel(api, "path_search_placeholder"),
- },
- {
- type: "list",
- variant: "cards",
- emptyText: formatLabel(api, "no_paths"),
- items: (pathData.paths || []).map((entry) => ({
- type: "row",
- variant: "card",
- children: [
- {
- type: "text",
- variant: "mono",
- value: shortHash(entry.destination_hash),
- },
- { type: "text", variant: "stat", value: hopLabel(api, entry.hops) },
- {
- type: "text",
- variant: "caption",
- value: shortInterface(entry.interface),
- },
- stateNode(api, entry.state),
- ],
- })),
- },
- ],
- },
- ],
- });
- }
-
- async function refresh() {
- await refreshPaths();
- render();
- }
-
- api.onAction(async (actionId) => {
- if (actionId === "refresh") {
- await refresh();
- } else if (actionId === "clear-announces") {
- announces = [];
- render();
- }
- });
-
- api.onEvent("announce.received", async (payload) => {
- announces.unshift({
- aspect: payload?.aspect || "",
- destination_hash: payload?.destination_hash || "",
- app_data: payload?.app_data || "",
- receivedAt: new Date().toLocaleTimeString(),
- });
- if (announces.length > MAX_ANNOUNCES) {
- announces = announces.slice(0, MAX_ANNOUNCES);
- }
- await refreshPaths();
- render();
- });
-
- api.onRefresh(refresh);
- await refresh();
-}
diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json b/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
deleted file mode 100644
index ab5a8a09..00000000
--- a/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "nav": "Mesh Observatory",
- "title": "Mesh Observatory",
- "description": "Watch live announces and browse your Reticulum path table in one place.",
- "announces_section": "Live announces",
- "announce_stats": "Showing {shown} of {total} captured announces",
- "filter": "Filter announces",
- "filter_placeholder": "Aspect, hash, or app data",
- "refresh": "Refresh paths",
- "clear_feed": "Clear announce feed",
- "no_announces": "No announces captured yet. Activity will appear here as the mesh announces.",
- "paths_section": "Path table",
- "path_stats": "{total} routes — {responsive} responsive, {unresponsive} unresponsive",
- "path_search": "Search paths",
- "path_search_placeholder": "Destination or via hash",
- "no_paths": "No paths match your search.",
- "hops_unknown": "Unknown hops",
- "hops_one": "1 hop",
- "hops_many": "{count} hops",
- "state_responsive": "Responsive",
- "state_unresponsive": "Unresponsive",
- "state_unknown": "Unknown"
-}
diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json b/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
deleted file mode 100644
index ddb63ca6..00000000
--- a/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "id": "com.meshchatx.mesh-observatory",
- "version": "1.0.0",
- "apiVersion": 1,
- "name": "Mesh Observatory",
- "description": "Live announce feed and searchable path table for your mesh.",
- "frontend": {
- "entry": "frontend/main.js",
- "type": "js"
- },
- "i18n": {
- "directory": "locales",
- "defaultLocale": "en"
- },
- "contributes": {
- "navItems": [
- {
- "id": "mesh-observatory",
- "route": { "name": "plugin-mesh-observatory" },
- "icon": "chart-line",
- "labelKey": "nav"
- }
- ],
- "toolsPageEntries": [
- {
- "name": "mesh-observatory",
- "route": { "name": "plugin-mesh-observatory" },
- "icon": "chart-line",
- "iconBg": "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
- "titleKey": "title",
- "descriptionKey": "description"
- }
- ]
- },
- "permissions": {
- "hooks": ["announce.received"],
- "managers": ["destinationPath.read"],
- "storage": "isolated",
- "network": "none"
- }
-}
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 858974ec..d86afc1f 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -16,6 +16,7 @@ from .crash_history import CrashHistoryDAO
from .debug_logs import DebugLogsDAO
from .gifs import UserGifsDAO
from .map_drawings import MapDrawingsDAO
+from .map_overlays import MapOverlaysDAO
from .messages import MessageDAO
from .misc import MiscDAO
from .provider import DatabaseProvider
@@ -81,6 +82,7 @@ class Database:
self.notification_sounds = NotificationSoundDAO(self.provider)
self.contacts = ContactsDAO(self.provider)
self.map_drawings = MapDrawingsDAO(self.provider)
+ self.map_overlays = MapOverlaysDAO(self.provider)
self.stickers = UserStickersDAO(self.provider)
self.sticker_packs = UserStickerPacksDAO(self.provider)
self.gifs = UserGifsDAO(self.provider)
@@ -96,8 +98,28 @@ class Database:
def execute_sql(self, query, params=None):
return self.provider.execute(query, params)
+ def _ensure_sqlite_temp_dir(self):
+ """Prefer a storage-local temp dir so Landlock can open spill files."""
+ try:
+ db_dir = self._identity_storage_dir()
+ except Exception:
+ return None
+ if not db_dir:
+ return None
+ temp_dir = os.path.join(db_dir, "sqlite-tmp")
+ try:
+ os.makedirs(temp_dir, exist_ok=True)
+ except OSError:
+ return None
+ # SQLite uses TMPDIR for PRAGMA temp_store=FILE spill files.
+ os.environ["TMPDIR"] = temp_dir
+ os.environ["TMP"] = temp_dir
+ os.environ["TEMP"] = temp_dir
+ return temp_dir
+
def _tune_sqlite_pragmas(self):
try:
+ self.provider.prefer_temp_store_file = False
self.execute_sql("PRAGMA journal_mode=WAL")
self.execute_sql("PRAGMA synchronous=NORMAL")
self.execute_sql("PRAGMA wal_autocheckpoint=1000")
@@ -109,15 +131,36 @@ class Database:
except Exception as exc:
print(f"SQLite pragma setup failed: {exc}")
- def apply_memory_pressure_pragmas(self, relax: bool) -> bool:
- """Move SQLite temp/cache work toward disk when host RAM is low."""
+ def apply_memory_pressure_pragmas(
+ self,
+ relax: bool,
+ *,
+ landlock_active: bool = False,
+ ) -> bool:
+ """Shrink SQLite cache under low RAM.
+
+ FILE temp spills break complex conversation queries under Landlock
+ (``unable to open database file``), even when TMPDIR is inside the
+ allowed storage tree. Keep MEMORY temp while Landlock is active and
+ only reduce cache/mmap. Without Landlock, FILE temp is still used.
+ """
try:
if relax:
- self.execute_sql("PRAGMA temp_store=FILE")
+ self._ensure_sqlite_temp_dir()
+ use_file_temp = not landlock_active
+ self.provider.prefer_temp_store_file = use_file_temp
+ if use_file_temp:
+ self.execute_sql("PRAGMA temp_store=FILE")
+ else:
+ self.execute_sql("PRAGMA temp_store=MEMORY")
+ _log.info(
+ "Memory pressure under Landlock: keeping temp_store=MEMORY",
+ )
self.execute_sql("PRAGMA cache_size=-2000") # 2 MB
self.execute_sql("PRAGMA mmap_size=0")
self._sqlite_memory_relaxed = True
else:
+ self.provider.prefer_temp_store_file = False
self.execute_sql("PRAGMA temp_store=MEMORY")
self.execute_sql("PRAGMA cache_size=-8000")
self.execute_sql("PRAGMA mmap_size=67108864")
@@ -217,7 +260,7 @@ class Database:
def check_db_health_at_open(self, storage_path):
"""Run integrity and baseline checks after opening the database.
- Returns human-readable issue strings; empty if healthy.
+ Returns human-readable issue strings. Empty if healthy.
"""
issues = []
try:
@@ -266,7 +309,7 @@ class Database:
def check_db_health_at_close(self, storage_path):
"""Run health checks before closing the database (for logging only).
- Returns issue strings; empty if healthy.
+ Returns issue strings. Empty if healthy.
"""
issues = []
try:
diff --git a/meshchatx/src/backend/database/map_overlays.py b/meshchatx/src/backend/database/map_overlays.py
new file mode 100644
index 00000000..2037e82b
--- /dev/null
+++ b/meshchatx/src/backend/database/map_overlays.py
@@ -0,0 +1,138 @@
+# SPDX-License-Identifier: 0BSD
+
+from datetime import UTC, datetime
+
+from .provider import DatabaseProvider
+
+
+class MapOverlaysDAO:
+ def __init__(self, provider: DatabaseProvider):
+ self.provider = provider
+
+ def count_for_identity(self, identity_hash: str) -> int:
+ row = self.provider.fetchone(
+ "SELECT COUNT(*) AS c FROM map_overlay_sources WHERE identity_hash = ?",
+ (identity_hash,),
+ )
+ return int(row["c"]) if row else 0
+
+ def get_by_id(self, overlay_id: int):
+ return self.provider.fetchone(
+ "SELECT * FROM map_overlay_sources WHERE id = ?",
+ (overlay_id,),
+ )
+
+ def get_by_unique(
+ self,
+ identity_hash: str,
+ kind: str,
+ destination_hash: str,
+ path_or_repo_path: str,
+ ref: str,
+ ):
+ return self.provider.fetchone(
+ """
+ SELECT * FROM map_overlay_sources
+ WHERE identity_hash = ?
+ AND kind = ?
+ AND destination_hash = ?
+ AND path_or_repo_path = ?
+ AND ref = ?
+ """,
+ (identity_hash, kind, destination_hash, path_or_repo_path, ref),
+ )
+
+ def list_for_identity(self, identity_hash: str):
+ return self.provider.fetchall(
+ """
+ SELECT * FROM map_overlay_sources
+ WHERE identity_hash = ?
+ ORDER BY updated_at DESC, id DESC
+ """,
+ (identity_hash,),
+ )
+
+ def list_due_autorefresh(self, now_iso: str):
+ return self.provider.fetchall(
+ """
+ SELECT * FROM map_overlay_sources
+ WHERE enabled = 1
+ AND refresh_interval_seconds > 0
+ AND status != 'fetching'
+ AND (
+ next_refresh_at IS NULL
+ OR next_refresh_at <= ?
+ )
+ ORDER BY (next_refresh_at IS NOT NULL), next_refresh_at ASC, id ASC
+ """,
+ (now_iso,),
+ )
+
+ def insert(
+ self,
+ identity_hash: str,
+ *,
+ kind: str,
+ destination_hash: str,
+ path_or_repo_path: str,
+ ref: str,
+ name: str,
+ group_name: str | None = None,
+ repository: str | None = None,
+ enabled: int = 1,
+ visible: int = 1,
+ refresh_interval_seconds: int = 0,
+ status: str = "pending",
+ ) -> int:
+ now = datetime.now(UTC)
+ cur = self.provider.execute(
+ """
+ INSERT INTO map_overlay_sources (
+ identity_hash, kind, destination_hash, path_or_repo_path, ref,
+ group_name, repository, name, enabled, visible,
+ refresh_interval_seconds, status, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ identity_hash,
+ kind,
+ destination_hash,
+ path_or_repo_path,
+ ref,
+ group_name,
+ repository,
+ name,
+ enabled,
+ visible,
+ refresh_interval_seconds,
+ status,
+ now,
+ now,
+ ),
+ )
+ return int(cur.lastrowid)
+
+ def update_fields(self, overlay_id: int, **fields) -> None:
+ if not fields:
+ return
+ fields = dict(fields)
+ fields["updated_at"] = datetime.now(UTC)
+ cols = ", ".join(f"{k} = ?" for k in fields)
+ values = list(fields.values()) + [overlay_id]
+ self.provider.execute(
+ f"UPDATE map_overlay_sources SET {cols} WHERE id = ?",
+ tuple(values),
+ )
+
+ def delete(self, overlay_id: int) -> None:
+ self.provider.execute(
+ "DELETE FROM map_overlay_sources WHERE id = ?",
+ (overlay_id,),
+ )
+
+ def delete_for_identity(self, identity_hash: str, overlay_id: int) -> bool:
+ row = self.get_by_id(overlay_id)
+ if not row or row["identity_hash"] != identity_hash:
+ return False
+ self.delete(overlay_id)
+ return True
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index 5f456cda..e3990f14 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -392,15 +392,50 @@ class MessageDAO:
rows = self.provider.fetchall(
"SELECT id, hash, peer_hash, source_hash, destination_hash, "
- "is_incoming, title, content, fields, timestamp "
+ "is_incoming, title, "
+ "substr(COALESCE(content, ''), 1, 240) as content, "
+ "CASE WHEN length(COALESCE(fields, '')) > 16384 THEN NULL ELSE fields END as fields, "
+ "CASE WHEN fields IS NOT NULL AND fields != '' AND fields != '{}' "
+ "AND (instr(fields, '\"image\"') > 0 OR instr(fields, '\"0x05\"') > 0 "
+ "OR instr(fields, '\"audio\"') > 0 OR instr(fields, '\"0x06\"') > 0 "
+ "OR instr(fields, '\"file_attachments\"') > 0 OR instr(fields, '\"0x07\"') > 0) "
+ "THEN 1 ELSE 0 END as has_attachments, "
+ "CASE WHEN fields IS NOT NULL AND fields != '' AND fields != '{}' "
+ "AND (instr(fields, '\"reaction\"') > 0 OR instr(fields, '\"0x40\"') > 0) "
+ "THEN 1 ELSE 0 END as has_reaction, "
+ "CASE WHEN fields IS NOT NULL AND fields != '' AND fields != '{}' "
+ "AND (instr(fields, '\"image\"') > 0 OR instr(fields, '\"0x05\"') > 0) "
+ "THEN 1 ELSE 0 END as has_image, "
+ "CASE WHEN fields IS NOT NULL AND fields != '' AND fields != '{}' "
+ "AND (instr(fields, '\"audio\"') > 0 OR instr(fields, '\"0x06\"') > 0) "
+ "THEN 1 ELSE 0 END as has_audio, "
+ "CASE WHEN fields IS NOT NULL AND fields != '' AND fields != '{}' "
+ "AND (instr(fields, '\"file_attachments\"') > 0 OR instr(fields, '\"0x07\"') > 0) "
+ "THEN 1 ELSE 0 END as has_files, "
+ "timestamp "
"FROM lxmf_messages WHERE peer_hash = ? AND is_incoming = 1 "
"ORDER BY timestamp DESC LIMIT ?",
(peer_hash, scan_limit),
)
for row in rows:
row_dict = dict(row) if not isinstance(row, dict) else row
+ fields = row_dict.get("fields")
+ if fields is None and (
+ row_dict.get("has_attachments")
+ or row_dict.get("has_image")
+ or row_dict.get("has_audio")
+ or row_dict.get("has_files")
+ ):
+ # Huge attachment blob omitted from SELECT: still user-facing.
+ return row_dict
+ if row_dict.get("has_reaction") and not (
+ (row_dict.get("content") and str(row_dict.get("content")).strip())
+ or (row_dict.get("title") and str(row_dict.get("title")).strip())
+ or row_dict.get("has_attachments")
+ ):
+ continue
if is_user_facing_lxmf_payload(
- row_dict.get("fields"),
+ fields,
row_dict.get("content"),
row_dict.get("title"),
):
diff --git a/meshchatx/src/backend/database/provider.py b/meshchatx/src/backend/database/provider.py
index 857bb406..c592c982 100644
--- a/meshchatx/src/backend/database/provider.py
+++ b/meshchatx/src/backend/database/provider.py
@@ -22,9 +22,13 @@ class DatabaseProvider:
self._local = threading.local()
self._all_locals.add(self._local)
self._memory_connection = None
+ # Per-connection default. Worker threads opened via asyncio.to_thread
+ # never see Database._tune_sqlite_pragmas(), so this must be set here.
+ # FILE temp under Landlock often fails with "unable to open database file"
+ # when SQLite spills sort/hash work for large conversation queries.
+ self.prefer_temp_store_file = False
- @staticmethod
- def _configure_connection(connection):
+ def _configure_connection(self, connection):
if connection is None:
return
try:
@@ -35,6 +39,21 @@ class DatabaseProvider:
connection.execute("PRAGMA journal_mode=WAL")
except sqlite3.OperationalError:
pass
+ try:
+ if self.prefer_temp_store_file:
+ connection.execute("PRAGMA temp_store=FILE")
+ connection.execute("PRAGMA cache_size=-2000")
+ connection.execute("PRAGMA mmap_size=0")
+ else:
+ connection.execute("PRAGMA temp_store=MEMORY")
+ connection.execute("PRAGMA cache_size=-8000")
+ connection.execute("PRAGMA mmap_size=67108864")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ connection.execute("PRAGMA synchronous=NORMAL")
+ except sqlite3.OperationalError:
+ pass
@classmethod
def get_instance(cls, db_path=None):
diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 0ea4c91f..b0ecaa3c 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -19,7 +19,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str:
class DatabaseSchema:
- LATEST_VERSION = 49
+ LATEST_VERSION = 50
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -441,6 +441,36 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
+ "map_overlay_sources": """
+ CREATE TABLE IF NOT EXISTS map_overlay_sources (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ identity_hash TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ destination_hash TEXT NOT NULL,
+ path_or_repo_path TEXT NOT NULL,
+ ref TEXT NOT NULL DEFAULT 'HEAD',
+ group_name TEXT,
+ repository TEXT,
+ name TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ visible INTEGER NOT NULL DEFAULT 1,
+ refresh_interval_seconds INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending',
+ last_error TEXT,
+ last_fetched_at DATETIME,
+ next_refresh_at DATETIME,
+ content_sha256 TEXT,
+ resolved_ref TEXT,
+ format TEXT,
+ byte_size INTEGER,
+ cache_relpath TEXT,
+ job_id TEXT,
+ generation INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(identity_hash, kind, destination_hash, path_or_repo_path, ref)
+ )
+ """,
"user_stickers": """
CREATE TABLE IF NOT EXISTS user_stickers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1318,3 +1348,43 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
+
+ if current_version < 50:
+ self._safe_execute("""
+ CREATE TABLE IF NOT EXISTS map_overlay_sources (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ identity_hash TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ destination_hash TEXT NOT NULL,
+ path_or_repo_path TEXT NOT NULL,
+ ref TEXT NOT NULL DEFAULT 'HEAD',
+ group_name TEXT,
+ repository TEXT,
+ name TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ visible INTEGER NOT NULL DEFAULT 1,
+ refresh_interval_seconds INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending',
+ last_error TEXT,
+ last_fetched_at DATETIME,
+ next_refresh_at DATETIME,
+ content_sha256 TEXT,
+ resolved_ref TEXT,
+ format TEXT,
+ byte_size INTEGER,
+ cache_relpath TEXT,
+ job_id TEXT,
+ generation INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(identity_hash, kind, destination_hash, path_or_repo_path, ref)
+ )
+ """)
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_map_overlay_sources_identity "
+ "ON map_overlay_sources(identity_hash)",
+ )
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_map_overlay_sources_refresh "
+ "ON map_overlay_sources(enabled, next_refresh_at)",
+ )
diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index 65436816..a2090a85 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -219,9 +219,18 @@ class DocsManager:
logging.exception(f"Failed to populate MeshChatX docs: {e}")
def _sync_docs_tree(self, src_docs, dest_dir):
- """Copy manifest, markdown, and text files from src_docs into dest_dir."""
- for root, _, files in os.walk(src_docs):
+ """Copy manifest, markdown, and text files from src_docs into dest_dir.
+
+ Skips ``agents/`` (contributor and automated-agent guidance, not
+ end-user documentation).
+ """
+ for root, dirnames, files in os.walk(src_docs):
rel_root = os.path.relpath(root, src_docs)
+ if rel_root == ".":
+ dirnames[:] = [d for d in dirnames if d != "agents"]
+ elif rel_root == "agents" or rel_root.startswith(f"agents{os.sep}"):
+ dirnames[:] = []
+ continue
target_root = (
dest_dir if rel_root == "." else os.path.join(dest_dir, rel_root)
)
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 7ba13b66..66f3464e 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -24,6 +24,7 @@ from meshchatx.src.backend.integrity_manager import (
select_critical_integrity_issues,
)
from meshchatx.src.backend.map_manager import MapManager
+from meshchatx.src.backend.map_overlay_manager import MapOverlayManager
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
from meshchatx.src.backend.message_handler import MessageHandler
from meshchatx.src.backend.nomadnet_utils import NomadNetworkManager
@@ -80,6 +81,7 @@ class IdentityContext:
self.announce_manager = None
self.archiver_manager = None
self.map_manager = None
+ self.map_overlay_manager = None
self.docs_manager = None
self.repository_server_manager = None
self.nomadnet_manager = None
@@ -192,6 +194,18 @@ class IdentityContext:
self.announce_manager = AnnounceManager(self.database, self.config)
self.archiver_manager = ArchiverManager(self.database)
self.map_manager = MapManager(self.config, self.app.storage_dir)
+ self.map_overlay_manager = MapOverlayManager(
+ self.config,
+ self.database,
+ self.storage_path,
+ reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
+ identity=self.identity,
+ reticulum=getattr(self.app, "reticulum", None),
+ )
+ try:
+ self.map_overlay_manager.start_scheduler()
+ except Exception:
+ pass
self.docs_manager = DocsManager(
self.config,
self.app.get_public_path(),
@@ -767,6 +781,12 @@ class IdentityContext:
if self.archiver_manager:
self.archiver_manager = None
+ if self.map_overlay_manager:
+ try:
+ self.map_overlay_manager.cleanup()
+ except Exception:
+ pass
+ self.map_overlay_manager = None
if self.map_manager:
self.map_manager = None
diff --git a/meshchatx/src/backend/identity_manager.py b/meshchatx/src/backend/identity_manager.py
index 4d406fdf..761fb642 100644
--- a/meshchatx/src/backend/identity_manager.py
+++ b/meshchatx/src/backend/identity_manager.py
@@ -185,20 +185,40 @@ class IdentityManager:
new_provider.close_all()
- # Save metadata
+ # Preserve icon/address metadata when re-importing an existing identity.
+ metadata_path = os.path.join(identity_dir, "metadata.json")
+ existing_metadata = {}
+ if os.path.exists(metadata_path):
+ with contextlib.suppress(Exception), open(metadata_path) as f:
+ loaded = json.load(f)
+ if isinstance(loaded, dict):
+ existing_metadata = loaded
+
+ resolved_name = (
+ (display_name or "").strip()
+ or existing_metadata.get("display_name")
+ or "Anonymous Peer"
+ )
metadata = {
- "display_name": display_name,
- "icon_name": None,
- "icon_foreground_colour": None,
- "icon_background_colour": None,
+ "display_name": resolved_name,
+ "icon_name": existing_metadata.get("icon_name"),
+ "icon_foreground_colour": existing_metadata.get(
+ "icon_foreground_colour",
+ ),
+ "icon_background_colour": existing_metadata.get(
+ "icon_background_colour",
+ ),
}
- metadata_path = os.path.join(identity_dir, "metadata.json")
+ for key in ("lxmf_address", "lxst_address"):
+ if key in existing_metadata:
+ metadata[key] = existing_metadata[key]
+
with open(metadata_path, "w") as f:
json.dump(metadata, f)
return {
"hash": identity_hash,
- "display_name": display_name,
+ "display_name": resolved_name,
}
def update_metadata_cache(self, identity_hash: str, metadata: dict):
@@ -229,11 +249,17 @@ class IdentityManager:
return True
return False
+ _MAX_IDENTITY_BYTES = 65536
+
def restore_identity_from_bytes(
self,
identity_bytes: bytes,
display_name: str | None = None,
) -> dict:
+ if not identity_bytes:
+ raise ValueError("Identity file is empty")
+ if len(identity_bytes) > self._MAX_IDENTITY_BYTES:
+ raise ValueError("Identity file is too large")
try:
# We use RNS.Identity.from_bytes to validate and get the hash
identity = RNS.Identity.from_bytes(identity_bytes)
@@ -242,6 +268,8 @@ class IdentityManager:
name = (display_name or "").strip() or "Restored Identity"
return self._save_new_identity(identity, name)
+ except ValueError:
+ raise
except Exception as exc:
raise ValueError(f"Failed to restore identity: {exc}") from exc
@@ -250,11 +278,16 @@ class IdentityManager:
base32_value: str,
display_name: str | None = None,
) -> dict:
+ if base32_value is None:
+ raise ValueError("base32 value is required")
+ normalized = "".join(str(base32_value).split())
+ if not normalized:
+ raise ValueError("base32 value is required")
try:
- identity_bytes = base64.b32decode(base32_value, casefold=True)
- return self.restore_identity_from_bytes(
- identity_bytes, display_name=display_name
- )
+ identity_bytes = base64.b32decode(normalized, casefold=True)
except Exception as exc:
msg = f"Invalid base32 identity: {exc}"
raise ValueError(msg) from exc
+ return self.restore_identity_from_bytes(
+ identity_bytes, display_name=display_name
+ )
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index 598e7f51..18260b30 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -7,7 +7,7 @@ import LXMF
from meshchatx.src.backend.telemetry_utils import Telemeter
-# MeshChatX app extensions (field 16); not used for LXMF-standard reactions.
+# MeshChatX app extensions (field 16). not used for LXMF-standard reactions.
LXMF_APP_EXTENSIONS_FIELD = 16
# LXMF reply / reaction field standards (see LXMF.py FIELD_REPLY_* / FIELD_REACTION)
@@ -256,27 +256,34 @@ def _lxmf_sidebar_actor_label(
)
+def _row_flag_true(row: dict, *keys) -> bool:
+ for key in keys:
+ value = row.get(key)
+ if value in (1, True, "1"):
+ return True
+ return False
+
+
def lxmf_sidebar_preview_for_conversation_latest_row(
row: dict,
*,
local_hash: str,
peer_display_name: str,
) -> str:
- """Single-line preview for conversation list APIs (reactions and some media have empty body)."""
+ """Single-line preview for conversation list APIs (reactions and some media have empty body).
+
+ Conversation list rows may omit full ``fields`` (to avoid loading multi-MB
+ attachment blobs). In that case SQL-derived flags such as ``has_image`` /
+ ``has_reaction`` are used instead.
+ """
content = row.get("content")
if content is not None and str(content).strip():
- return str(content)
-
- fields_raw = row.get("fields")
- try:
- if isinstance(fields_raw, str):
- fields = json.loads(fields_raw) if fields_raw else {}
- elif isinstance(fields_raw, dict):
- fields = fields_raw
- else:
- fields = {}
- except (json.JSONDecodeError, TypeError):
- fields = {}
+ # List queries may already truncate content. keep previews bounded.
+ text = str(content)
+ stripped = text.strip()
+ if len(stripped) <= 240:
+ return text if len(text) <= 240 else stripped[:237] + "..."
+ return stripped[:237] + "..."
actor = _lxmf_sidebar_actor_label(
row,
@@ -285,55 +292,88 @@ def lxmf_sidebar_preview_for_conversation_latest_row(
)
incoming = bool(row.get("is_incoming"))
- emoji = _reaction_emoji_from_parsed_lxmf_fields(fields)
- if emoji:
- return f"{actor} reacted {emoji}"
+ fields_raw = row.get("fields")
+ fields = {}
+ if fields_raw is not None:
+ try:
+ if isinstance(fields_raw, str):
+ # Never json.loads multi-MB attachment blobs for a sidebar line.
+ if len(fields_raw) > 16384:
+ fields = {}
+ else:
+ fields = json.loads(fields_raw) if fields_raw else {}
+ elif isinstance(fields_raw, dict):
+ fields = fields_raw
+ except (json.JSONDecodeError, TypeError):
+ fields = {}
- telemetry = fields.get("telemetry")
- if isinstance(telemetry, dict):
- loc = telemetry.get("location")
- if isinstance(loc, dict) and loc:
+ if fields:
+ emoji = _reaction_emoji_from_parsed_lxmf_fields(fields)
+ if emoji:
+ return f"{actor} reacted {emoji}"
+
+ telemetry = fields.get("telemetry")
+ if isinstance(telemetry, dict):
+ loc = telemetry.get("location")
+ if isinstance(loc, dict) and loc:
+ if actor == "You":
+ return "You shared your location"
+ return f"{actor} shared their location"
+
+ ts = fields.get("telemetry_stream")
+ if isinstance(ts, list) and len(ts) > 0:
+ return f"{actor} sent a telemetry stream"
+
+ if isinstance(telemetry, dict) and len(telemetry) > 0:
+ return f"{actor} sent telemetry"
+
+ commands = fields.get("commands")
+ if isinstance(commands, list):
+ for cmd in commands:
+ if isinstance(cmd, dict) and "0x01" in cmd:
+ if incoming:
+ return f"{actor} requested your location"
+ return f"{actor} sent a location request"
+
+ image = fields.get("image")
+ if isinstance(image, dict) and image:
if actor == "You":
- return "You shared your location"
- return f"{actor} shared their location"
-
- ts = fields.get("telemetry_stream")
- if isinstance(ts, list) and len(ts) > 0:
- return f"{actor} sent a telemetry stream"
+ return "You sent an image"
+ return f"{actor} sent an image"
- if isinstance(telemetry, dict) and len(telemetry) > 0:
- return f"{actor} sent telemetry"
-
- commands = fields.get("commands")
- if isinstance(commands, list):
- for cmd in commands:
- if isinstance(cmd, dict) and "0x01" in cmd:
- if incoming:
- return f"{actor} requested your location"
- return f"{actor} sent a location request"
+ audio = fields.get("audio")
+ if isinstance(audio, dict) and audio:
+ if actor == "You":
+ return "You sent a voice note"
+ return f"{actor} sent a voice note"
+
+ file_attachments = fields.get("file_attachments")
+ if isinstance(file_attachments, list) and len(file_attachments) > 0:
+ n = len(file_attachments)
+ if n == 1:
+ if actor == "You":
+ return "You sent a file"
+ return f"{actor} sent a file"
+ if actor == "You":
+ return f"You sent {n} files"
+ return f"{actor} sent {n} files"
- image = fields.get("image")
- if isinstance(image, dict) and image:
+ if _row_flag_true(row, "has_reaction"):
+ return f"{actor} reacted"
+ if _row_flag_true(row, "has_image"):
if actor == "You":
return "You sent an image"
return f"{actor} sent an image"
-
- audio = fields.get("audio")
- if isinstance(audio, dict) and audio:
+ if _row_flag_true(row, "has_audio"):
if actor == "You":
return "You sent a voice note"
return f"{actor} sent a voice note"
-
- file_attachments = fields.get("file_attachments")
- if isinstance(file_attachments, list) and len(file_attachments) > 0:
- n = len(file_attachments)
- if n == 1:
- if actor == "You":
- return "You sent a file"
- return f"{actor} sent a file"
+ if _row_flag_true(row, "has_files"):
if actor == "You":
- return f"You sent {n} files"
- return f"{actor} sent {n} files"
+ return "You sent a file"
+ return f"{actor} sent a file"
+ if _row_flag_true(row, "has_telemetry"):
+ return f"{actor} sent telemetry"
return str(content or "")
@@ -811,7 +851,7 @@ def compute_lxmf_conversation_unread_from_latest_row(row, *, require_user_facing
"""Return whether the conversation row should appear as unread.
Uses ``lxmf_conversation_read_state.last_read_at`` only. The latest message
- must be incoming; outbound-only threads are not unread (matches
+ must be incoming. outbound-only threads are not unread (matches
``filter_unread`` in ``MessageHandler.get_conversations``).
When ``require_user_facing`` is True, the row's latest message must also be
@@ -823,16 +863,32 @@ def compute_lxmf_conversation_unread_from_latest_row(row, *, require_user_facing
if not row.get("is_incoming"):
return False
- if require_user_facing and not is_user_facing_lxmf_payload(
- row.get("fields"),
- row.get("content"),
- row.get("title"),
- ):
- return False
+ if require_user_facing:
+ if row.get("fields") is not None:
+ if not is_user_facing_lxmf_payload(
+ row.get("fields"),
+ row.get("content"),
+ row.get("title"),
+ ):
+ return False
+ elif _row_flag_true(row, "has_reaction") and not (
+ (row.get("content") and str(row.get("content")).strip())
+ or (row.get("title") and str(row.get("title")).strip())
+ or _row_flag_true(
+ row, "has_image", "has_audio", "has_files", "has_attachments"
+ )
+ ):
+ return False
last_read_at_raw = row.get("last_read_at")
if not last_read_at_raw:
return True
- last_read_at = datetime.fromisoformat(last_read_at_raw)
+ try:
+ last_read_at = datetime.fromisoformat(str(last_read_at_raw))
+ except (TypeError, ValueError):
+ return True
if last_read_at.tzinfo is None:
last_read_at = last_read_at.replace(tzinfo=UTC)
- return row["timestamp"] > last_read_at.timestamp()
+ try:
+ return float(row["timestamp"]) > last_read_at.timestamp()
+ except (TypeError, ValueError, KeyError):
+ return False
diff --git a/meshchatx/src/backend/map_geo_validator.py b/meshchatx/src/backend/map_geo_validator.py
new file mode 100644
index 00000000..369b2ec6
--- /dev/null
+++ b/meshchatx/src/backend/map_geo_validator.py
@@ -0,0 +1,259 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Validate GeoJSON / KML / KMZ bytes for map overlay import."""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from dataclasses import dataclass
+from io import BytesIO
+from xml.etree import ElementTree as ET
+
+ZIP_LOCAL_HEADER = b"PK\x03\x04"
+MAX_KMZ_ENTRIES = 512
+MAX_COMPRESSION_RATIO = 100.0
+
+
+class GeoValidationError(ValueError):
+ def __init__(self, code: str, message: str | None = None):
+ self.code = code
+ super().__init__(message or code)
+
+
+@dataclass
+class GeoValidationResult:
+ format: str
+ feature_count: int
+ byte_size: int
+
+
+def _looks_like_html(text: str) -> bool:
+ head = text.lstrip()[:200].lower()
+ return head.startswith("<!doctype html") or head.startswith("<html")
+
+
+def _count_geojson_features(obj) -> int:
+ if not isinstance(obj, dict):
+ raise GeoValidationError("invalid_geojson")
+ t = obj.get("type")
+ if t == "FeatureCollection":
+ feats = obj.get("features")
+ if not isinstance(feats, list):
+ raise GeoValidationError("invalid_geojson")
+ return len(feats)
+ if t == "Feature":
+ return 1
+ if t in (
+ "Point",
+ "MultiPoint",
+ "LineString",
+ "MultiLineString",
+ "Polygon",
+ "MultiPolygon",
+ "GeometryCollection",
+ ):
+ return 1
+ raise GeoValidationError("invalid_geojson")
+
+
+def _validate_coords_finite(obj, *, depth: int = 0) -> None:
+ if depth > 32:
+ raise GeoValidationError("geometry_too_deep")
+ if isinstance(obj, dict):
+ if "coordinates" in obj:
+ _walk_coords(obj["coordinates"], depth=0)
+ if "geometries" in obj and isinstance(obj["geometries"], list):
+ for g in obj["geometries"]:
+ _validate_coords_finite(g, depth=depth + 1)
+ if "geometry" in obj and obj["geometry"] is not None:
+ _validate_coords_finite(obj["geometry"], depth=depth + 1)
+ if "features" in obj and isinstance(obj["features"], list):
+ for f in obj["features"]:
+ _validate_coords_finite(f, depth=depth + 1)
+
+
+def _walk_coords(node, *, depth: int) -> None:
+ if depth > 16:
+ raise GeoValidationError("geometry_too_deep")
+ if isinstance(node, (int, float)):
+ if node != node or node in (float("inf"), float("-inf")):
+ raise GeoValidationError("invalid_coordinates")
+ return
+ if isinstance(node, list):
+ if node and all(isinstance(x, (int, float)) for x in node):
+ if len(node) < 2:
+ raise GeoValidationError("invalid_coordinates")
+ lon, lat = float(node[0]), float(node[1])
+ if lon != lon or lat != lat:
+ raise GeoValidationError("invalid_coordinates")
+ if lon < -180.0 or lon > 180.0 or lat < -90.0 or lat > 90.0:
+ raise GeoValidationError("coordinates_out_of_range")
+ return
+ for item in node:
+ _walk_coords(item, depth=depth + 1)
+ return
+ raise GeoValidationError("invalid_coordinates")
+
+
+def validate_geojson_bytes(
+ data: bytes,
+ *,
+ max_bytes: int,
+ max_features: int,
+) -> GeoValidationResult:
+ if len(data) > max_bytes:
+ raise GeoValidationError("file_too_large")
+ try:
+ text = data.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise GeoValidationError("invalid_encoding") from exc
+ if _looks_like_html(text):
+ raise GeoValidationError("not_geo_content")
+ try:
+ obj = json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise GeoValidationError("invalid_geojson") from exc
+ count = _count_geojson_features(obj)
+ if count > max_features:
+ raise GeoValidationError("too_many_features")
+ _validate_coords_finite(obj)
+ return GeoValidationResult(
+ format="geojson", feature_count=count, byte_size=len(data)
+ )
+
+
+def _strip_ns(tag: str) -> str:
+ if "}" in tag:
+ return tag.rsplit("}", 1)[-1]
+ return tag
+
+
+def validate_kml_bytes(
+ data: bytes,
+ *,
+ max_bytes: int,
+ max_features: int,
+) -> GeoValidationResult:
+ if len(data) > max_bytes:
+ raise GeoValidationError("file_too_large")
+ try:
+ text = data.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise GeoValidationError("invalid_encoding") from exc
+ if _looks_like_html(text):
+ raise GeoValidationError("not_geo_content")
+ try:
+ root = ET.fromstring(text)
+ except ET.ParseError as exc:
+ raise GeoValidationError("invalid_kml") from exc
+ if _strip_ns(root.tag).lower() != "kml":
+ raise GeoValidationError("invalid_kml")
+ placemarks = [el for el in root.iter() if _strip_ns(el.tag).lower() == "placemark"]
+ count = len(placemarks)
+ if count > max_features:
+ raise GeoValidationError("too_many_features")
+ return GeoValidationResult(format="kml", feature_count=count, byte_size=len(data))
+
+
+def validate_kmz_bytes(
+ data: bytes,
+ *,
+ max_bytes: int,
+ max_uncompressed_bytes: int,
+ max_features: int,
+) -> GeoValidationResult:
+ if len(data) > max_bytes:
+ raise GeoValidationError("file_too_large")
+ if not data.startswith(ZIP_LOCAL_HEADER):
+ raise GeoValidationError("invalid_kmz")
+ try:
+ zf = zipfile.ZipFile(BytesIO(data))
+ except zipfile.BadZipFile as exc:
+ raise GeoValidationError("invalid_kmz") from exc
+ with zf:
+ infos = [i for i in zf.infolist() if not i.is_dir()]
+ if len(infos) > MAX_KMZ_ENTRIES:
+ raise GeoValidationError("kmz_too_many_entries")
+ total_uncomp = 0
+ kml_name = None
+ for info in infos:
+ name = info.filename.replace("\\", "/")
+ if ".." in name.split("/"):
+ raise GeoValidationError("path_traversal")
+ total_uncomp += int(info.file_size)
+ if total_uncomp > max_uncompressed_bytes:
+ raise GeoValidationError("kmz_uncompressed_too_large")
+ if info.compress_size > 0:
+ ratio = float(info.file_size) / float(info.compress_size)
+ if ratio > MAX_COMPRESSION_RATIO and info.file_size > 1024 * 1024:
+ raise GeoValidationError("kmz_compression_ratio")
+ lower = name.lower()
+ if lower.endswith(".kml"):
+ if kml_name is None or lower.endswith("doc.kml") or lower == "doc.kml":
+ if lower == "doc.kml" or lower.endswith("/doc.kml"):
+ kml_name = name
+ elif kml_name is None:
+ kml_name = name
+ if not kml_name:
+ raise GeoValidationError("kmz_missing_kml")
+ kml_bytes = zf.read(kml_name)
+ kml_result = validate_kml_bytes(
+ kml_bytes,
+ max_bytes=max_uncompressed_bytes,
+ max_features=max_features,
+ )
+ return GeoValidationResult(
+ format="kmz",
+ feature_count=kml_result.feature_count,
+ byte_size=len(data),
+ )
+
+
+def sniff_format(data: bytes, hinted: str | None = None) -> str:
+ if hinted in ("geojson", "kml", "kmz"):
+ return hinted
+ if data.startswith(ZIP_LOCAL_HEADER):
+ return "kmz"
+ sample = data[:256].lstrip()
+ if sample.startswith(b"{") or sample.startswith(b"["):
+ return "geojson"
+ lower = sample.lower()
+ if lower.startswith(b"<kml") or b"<kml" in lower[:64]:
+ return "kml"
+ if lower.startswith(b"<?xml"):
+ return "kml"
+ raise GeoValidationError("unknown_format")
+
+
+def validate_geo_bytes(
+ data: bytes,
+ *,
+ hinted_format: str | None = None,
+ max_bytes: int,
+ max_features: int,
+ max_kmz_uncompressed_bytes: int,
+) -> GeoValidationResult:
+ if not data:
+ raise GeoValidationError("empty_file")
+ fmt = sniff_format(data, hinted_format)
+ if fmt == "geojson":
+ return validate_geojson_bytes(
+ data,
+ max_bytes=max_bytes,
+ max_features=max_features,
+ )
+ if fmt == "kml":
+ return validate_kml_bytes(
+ data,
+ max_bytes=max_bytes,
+ max_features=max_features,
+ )
+ if fmt == "kmz":
+ return validate_kmz_bytes(
+ data,
+ max_bytes=max_bytes,
+ max_uncompressed_bytes=max_kmz_uncompressed_bytes,
+ max_features=max_features,
+ )
+ raise GeoValidationError("unknown_format")
diff --git a/meshchatx/src/backend/map_overlay_export.py b/meshchatx/src/backend/map_overlay_export.py
new file mode 100644
index 00000000..2d89ab31
--- /dev/null
+++ b/meshchatx/src/backend/map_overlay_export.py
@@ -0,0 +1,282 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Export / transcode cached map overlays between GeoJSON, KML, and KMZ."""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from io import BytesIO
+from xml.etree import ElementTree as ET
+from xml.sax.saxutils import escape
+
+from meshchatx.src.backend.map_geo_validator import (
+ GeoValidationError,
+ sniff_format,
+ validate_geo_bytes,
+)
+
+
+class OverlayExportError(ValueError):
+ def __init__(self, code: str, message: str | None = None):
+ self.code = code
+ super().__init__(message or code)
+
+
+def _strip_ns(tag: str) -> str:
+ if "}" in tag:
+ return tag.rsplit("}", 1)[-1]
+ return tag
+
+
+def _coords_to_kml(coords, geom_type: str) -> str:
+ if geom_type == "Point":
+ lon, lat = coords[0], coords[1]
+ alt = coords[2] if len(coords) > 2 else 0
+ return f"{lon},{lat},{alt}"
+ if geom_type in ("LineString", "MultiPoint"):
+ parts = []
+ for c in coords:
+ lon, lat = c[0], c[1]
+ alt = c[2] if len(c) > 2 else 0
+ parts.append(f"{lon},{lat},{alt}")
+ return " ".join(parts)
+ if geom_type == "Polygon":
+ # outer ring only for simple export
+ ring = coords[0] if coords else []
+ return _coords_to_kml(ring, "LineString")
+ return ""
+
+
+def geojson_to_kml(data: bytes) -> bytes:
+ obj = json.loads(data.decode("utf-8"))
+ features = []
+ if obj.get("type") == "FeatureCollection":
+ features = obj.get("features") or []
+ elif obj.get("type") == "Feature":
+ features = [obj]
+ else:
+ features = [{"type": "Feature", "properties": {}, "geometry": obj}]
+
+ parts = [
+ '<?xml version="1.0" encoding="UTF-8"?>',
+ '<kml xmlns="http://www.opengis.net/kml/2.2"><Document>',
+ ]
+ for feat in features:
+ if not isinstance(feat, dict):
+ continue
+ props = feat.get("properties") or {}
+ name = escape(str(props.get("name") or props.get("title") or "feature"))
+ geom = feat.get("geometry") or {}
+ gtype = geom.get("type")
+ coords = geom.get("coordinates")
+ if not gtype or coords is None:
+ continue
+ parts.append("<Placemark>")
+ parts.append(f"<name>{name}</name>")
+ if gtype == "Point":
+ parts.append(
+ f"<Point><coordinates>{_coords_to_kml(coords, 'Point')}</coordinates></Point>",
+ )
+ elif gtype == "LineString":
+ parts.append(
+ f"<LineString><coordinates>{_coords_to_kml(coords, 'LineString')}</coordinates></LineString>",
+ )
+ elif gtype == "Polygon":
+ parts.append(
+ "<Polygon><outerBoundaryIs><LinearRing>"
+ f"<coordinates>{_coords_to_kml(coords, 'Polygon')}</coordinates>"
+ "</LinearRing></outerBoundaryIs></Polygon>",
+ )
+ else:
+ # Skip complex geometries in simple transcoder
+ parts.append("</Placemark>")
+ continue
+ parts.append("</Placemark>")
+ parts.append("</Document></kml>")
+ return "\n".join(parts).encode("utf-8")
+
+
+def kml_to_geojson(data: bytes) -> bytes:
+ root = ET.fromstring(data.decode("utf-8"))
+ features = []
+ for pm in root.iter():
+ if _strip_ns(pm.tag).lower() != "placemark":
+ continue
+ name = None
+ geom = None
+ for child in list(pm):
+ tag = _strip_ns(child.tag).lower()
+ if tag == "name":
+ name = (child.text or "").strip()
+ elif tag == "point":
+ coords_el = next(
+ (
+ c
+ for c in child.iter()
+ if _strip_ns(c.tag).lower() == "coordinates"
+ ),
+ None,
+ )
+ if coords_el is not None and coords_el.text:
+ parts = coords_el.text.strip().split(",")
+ if len(parts) >= 2:
+ geom = {
+ "type": "Point",
+ "coordinates": [float(parts[0]), float(parts[1])],
+ }
+ elif tag == "linestring":
+ coords_el = next(
+ (
+ c
+ for c in child.iter()
+ if _strip_ns(c.tag).lower() == "coordinates"
+ ),
+ None,
+ )
+ if coords_el is not None and coords_el.text:
+ line = []
+ for token in coords_el.text.strip().split():
+ bits = token.split(",")
+ if len(bits) >= 2:
+ line.append([float(bits[0]), float(bits[1])])
+ if line:
+ geom = {"type": "LineString", "coordinates": line}
+ elif tag == "polygon":
+ coords_el = next(
+ (
+ c
+ for c in child.iter()
+ if _strip_ns(c.tag).lower() == "coordinates"
+ ),
+ None,
+ )
+ if coords_el is not None and coords_el.text:
+ ring = []
+ for token in coords_el.text.strip().split():
+ bits = token.split(",")
+ if len(bits) >= 2:
+ ring.append([float(bits[0]), float(bits[1])])
+ if ring:
+ geom = {"type": "Polygon", "coordinates": [ring]}
+ if geom is None:
+ continue
+ features.append(
+ {
+ "type": "Feature",
+ "properties": {"name": name} if name else {},
+ "geometry": geom,
+ },
+ )
+ return json.dumps(
+ {"type": "FeatureCollection", "features": features},
+ separators=(",", ":"),
+ ).encode("utf-8")
+
+
+def kmz_to_kml(data: bytes) -> bytes:
+ with zipfile.ZipFile(BytesIO(data)) as zf:
+ names = [n for n in zf.namelist() if not n.endswith("/")]
+ kml_name = None
+ for n in names:
+ lower = n.replace("\\", "/").lower()
+ if lower == "doc.kml" or lower.endswith("/doc.kml"):
+ kml_name = n
+ break
+ if kml_name is None:
+ for n in names:
+ if n.lower().endswith(".kml"):
+ kml_name = n
+ break
+ if kml_name is None:
+ raise OverlayExportError("kmz_missing_kml")
+ return zf.read(kml_name)
+
+
+def kml_to_kmz(kml_bytes: bytes) -> bytes:
+ buf = BytesIO()
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("doc.kml", kml_bytes)
+ return buf.getvalue()
+
+
+def merge_geojson_bytes(chunks: list[bytes]) -> bytes:
+ features = []
+ for chunk in chunks:
+ obj = json.loads(chunk.decode("utf-8"))
+ if obj.get("type") == "FeatureCollection":
+ features.extend(obj.get("features") or [])
+ elif obj.get("type") == "Feature":
+ features.append(obj)
+ else:
+ features.append(
+ {"type": "Feature", "properties": {}, "geometry": obj},
+ )
+ return json.dumps(
+ {"type": "FeatureCollection", "features": features},
+ separators=(",", ":"),
+ ).encode("utf-8")
+
+
+def to_geojson(data: bytes, source_format: str | None = None) -> bytes:
+ fmt = source_format or sniff_format(data)
+ if fmt == "geojson":
+ return data
+ if fmt == "kml":
+ return kml_to_geojson(data)
+ if fmt == "kmz":
+ return kml_to_geojson(kmz_to_kml(data))
+ raise OverlayExportError("unknown_format")
+
+
+def from_geojson(geojson_bytes: bytes, target_format: str) -> bytes:
+ if target_format == "geojson":
+ return geojson_bytes
+ if target_format == "kml":
+ return geojson_to_kml(geojson_bytes)
+ if target_format == "kmz":
+ return kml_to_kmz(geojson_to_kml(geojson_bytes))
+ raise OverlayExportError("unknown_format")
+
+
+def convert_overlay_bytes(
+ data: bytes,
+ *,
+ source_format: str | None,
+ target_format: str,
+ max_bytes: int,
+ max_features: int,
+ max_kmz_uncompressed_bytes: int,
+) -> bytes:
+ if target_format not in ("geojson", "kml", "kmz"):
+ raise OverlayExportError("invalid_export_format")
+ src = source_format or sniff_format(data)
+ if src == target_format:
+ out = data
+ else:
+ gj = to_geojson(data, src)
+ out = from_geojson(gj, target_format)
+ try:
+ validate_geo_bytes(
+ out,
+ hinted_format=target_format,
+ max_bytes=max_bytes,
+ max_features=max_features,
+ max_kmz_uncompressed_bytes=max_kmz_uncompressed_bytes,
+ )
+ except GeoValidationError as exc:
+ raise OverlayExportError(exc.code) from exc
+ return out
+
+
+CONTENT_TYPES = {
+ "geojson": "application/geo+json",
+ "kml": "application/vnd.google-earth.kml+xml",
+ "kmz": "application/vnd.google-earth.kmz",
+}
+
+EXTENSIONS = {
+ "geojson": ".geojson",
+ "kml": ".kml",
+ "kmz": ".kmz",
+}
diff --git a/meshchatx/src/backend/map_overlay_manager.py b/meshchatx/src/backend/map_overlay_manager.py
new file mode 100644
index 00000000..a6cff9a0
--- /dev/null
+++ b/meshchatx/src/backend/map_overlay_manager.py
@@ -0,0 +1,861 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Manage remote map overlay sources: fetch, cache, refresh, export."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import logging
+import os
+import random
+import re
+import shutil
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from meshchatx.src.backend.map_geo_validator import (
+ GeoValidationError,
+ validate_geo_bytes,
+)
+from meshchatx.src.backend.map_overlay_export import (
+ CONTENT_TYPES,
+ EXTENSIONS,
+ OverlayExportError,
+ convert_overlay_bytes,
+ merge_geojson_bytes,
+ to_geojson,
+ from_geojson,
+)
+from meshchatx.src.backend.map_overlay_sources import (
+ KIND_NOMADNET_FILE,
+ KIND_RNGIT_FILES,
+ OverlaySourceParseError,
+ OverlaySourceSpec,
+ guess_format_from_path,
+ parse_create_payload,
+)
+from meshchatx.src.backend.nomadnet_downloader import NomadnetFileDownloader
+from meshchatx.src.backend.rngit_sparse_fetcher import (
+ RngitFetchError,
+ RngitSparseFetcher,
+)
+
+_log = logging.getLogger("meshchatx.map_overlays")
+
+_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
+
+CONFIG_CLAMPS = {
+ "map_overlay_max_bytes": (64 * 1024, 64 * 1024 * 1024),
+ "map_overlay_max_features": (100, 500_000),
+ "map_overlay_max_kmz_uncompressed_bytes": (256 * 1024, 128 * 1024 * 1024),
+ "map_overlay_max_sources": (1, 256),
+ "map_overlay_max_concurrent_jobs": (1, 8),
+ "map_overlay_path_timeout_seconds": (5, 300),
+ "map_overlay_transfer_timeout_seconds": (15, 600),
+ "map_overlay_job_timeout_seconds": (30, 1800),
+ "map_overlay_max_retries": (0, 10),
+ "map_overlay_retry_delay_seconds": (1, 120),
+}
+
+
+def clamp_overlay_config_value(key: str, value: int) -> int:
+ lo, hi = CONFIG_CLAMPS[key]
+ return max(lo, min(hi, int(value)))
+
+
+def atomic_write_bytes(path: str, data: bytes) -> None:
+ parent = os.path.dirname(path)
+ os.makedirs(parent, exist_ok=True)
+ tmp = path + ".tmp"
+ with open(tmp, "wb") as f:
+ f.write(data)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp, path)
+
+
+def _safe_filename(name: str, ext: str) -> str:
+ base = _SAFE_NAME_RE.sub("_", (name or "overlay").strip())[:80] or "overlay"
+ if not ext.startswith("."):
+ ext = "." + ext
+ return base + ext
+
+
+def _row_to_dict(row) -> dict[str, Any]:
+ if row is None:
+ return {}
+ if hasattr(row, "keys"):
+ return {k: row[k] for k in row.keys()}
+ return dict(row)
+
+
+class MapOverlayManager:
+ def __init__(
+ self,
+ config,
+ database,
+ storage_dir: str,
+ *,
+ reticulum_config_dir: str | None = None,
+ identity=None,
+ reticulum=None,
+ file_downloader_factory=None,
+ rngit_fetcher_factory=None,
+ ):
+ self.config = config
+ self.database = database
+ self.storage_dir = storage_dir
+ self.reticulum_config_dir = reticulum_config_dir
+ self.identity = identity
+ self.reticulum = reticulum
+ self._file_downloader_factory = (
+ file_downloader_factory or self._default_file_downloader_factory
+ )
+ self._rngit_fetcher_factory = (
+ rngit_fetcher_factory or self._default_rngit_fetcher_factory
+ )
+
+ self._jobs: dict[str, dict[str, Any]] = {}
+ self._source_locks: dict[int, asyncio.Lock] = {}
+ self._active_fetchers: dict[str, Any] = {}
+ self._job_semaphore: asyncio.Semaphore | None = None
+ self._scheduler_task: asyncio.Task | None = None
+ self._stopped = False
+
+ def overlay_root(self) -> str:
+ path = os.path.join(self.storage_dir, "map_overlays")
+ os.makedirs(path, exist_ok=True)
+ return path
+
+ def work_root(self) -> str:
+ path = os.path.join(self.overlay_root(), ".work")
+ os.makedirs(path, exist_ok=True)
+ return path
+
+ def cache_path_for(self, identity_hash: str, overlay_id: int, fmt: str) -> str:
+ rel = os.path.join(identity_hash, f"{overlay_id}.{fmt}")
+ return os.path.join(self.overlay_root(), rel), rel
+
+ def _cfg_int(self, key: str) -> int:
+ conf = getattr(self.config, key)
+ raw = conf.get()
+ return clamp_overlay_config_value(key, int(raw))
+
+ def limits(self) -> dict[str, int]:
+ return {k: self._cfg_int(k) for k in CONFIG_CLAMPS}
+
+ def _source_lock(self, overlay_id: int) -> asyncio.Lock:
+ lock = self._source_locks.get(overlay_id)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._source_locks[overlay_id] = lock
+ return lock
+
+ def _default_file_downloader_factory(self, **kwargs):
+ return NomadnetFileDownloader(**kwargs)
+
+ def _default_rngit_fetcher_factory(self, **kwargs):
+ return RngitSparseFetcher(**kwargs)
+
+ def start_scheduler(self) -> None:
+ if self._scheduler_task is not None:
+ return
+ self._stopped = False
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ return
+ self._scheduler_task = loop.create_task(self._autorefresh_loop())
+
+ def stop_scheduler(self) -> None:
+ self._stopped = True
+ if self._scheduler_task is not None:
+ self._scheduler_task.cancel()
+ self._scheduler_task = None
+ for fetcher in list(self._active_fetchers.values()):
+ try:
+ fetcher.cancel()
+ except Exception:
+ pass
+
+ async def _autorefresh_loop(self) -> None:
+ while not self._stopped:
+ try:
+ await self._tick_autorefresh()
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ _log.exception("map overlay autorefresh tick failed")
+ await asyncio.sleep(15)
+
+ async def _tick_autorefresh(self) -> None:
+ now = datetime.now(UTC).isoformat()
+ rows = self.database.map_overlays.list_due_autorefresh(now)
+ for row in rows:
+ overlay_id = int(row["id"])
+ identity_hash = row["identity_hash"]
+ if self._source_lock(overlay_id).locked():
+ continue
+ try:
+ await self.refresh_overlay(
+ identity_hash, overlay_id, reason="autorefresh"
+ )
+ except Exception:
+ _log.exception("autorefresh failed for overlay %s", overlay_id)
+
+ def list_overlays(self, identity_hash: str) -> list[dict[str, Any]]:
+ rows = self.database.map_overlays.list_for_identity(identity_hash)
+ return [_row_to_dict(r) for r in rows]
+
+ def get_overlay(self, identity_hash: str, overlay_id: int) -> dict[str, Any] | None:
+ row = self.database.map_overlays.get_by_id(overlay_id)
+ if not row or row["identity_hash"] != identity_hash:
+ return None
+ return _row_to_dict(row)
+
+ def get_job(self, job_id: str) -> dict[str, Any] | None:
+ return self._jobs.get(job_id)
+
+ async def create_overlays(
+ self,
+ identity_hash: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ specs = parse_create_payload(payload)
+ max_sources = self._cfg_int("map_overlay_max_sources")
+ current = self.database.map_overlays.count_for_identity(identity_hash)
+ if current + len(specs) > max_sources:
+ raise OverlaySourceParseError("max_sources_exceeded")
+
+ created_ids: list[int] = []
+ for spec in specs:
+ existing = self.database.map_overlays.get_by_unique(
+ identity_hash,
+ spec.kind,
+ spec.destination_hash,
+ spec.path_or_repo_path,
+ spec.ref,
+ )
+ if existing:
+ created_ids.append(int(existing["id"]))
+ continue
+ oid = self.database.map_overlays.insert(
+ identity_hash,
+ kind=spec.kind,
+ destination_hash=spec.destination_hash,
+ path_or_repo_path=spec.path_or_repo_path,
+ ref=spec.ref,
+ name=spec.name or "overlay",
+ group_name=spec.group_name,
+ repository=spec.repository,
+ refresh_interval_seconds=spec.refresh_interval_seconds,
+ status="pending",
+ )
+ created_ids.append(oid)
+
+ job_id = await self._start_job_for_ids(identity_hash, created_ids, specs)
+ overlays = [self.get_overlay(identity_hash, i) for i in created_ids]
+ return {"job_id": job_id, "overlays": overlays}
+
+ async def refresh_overlay(
+ self,
+ identity_hash: str,
+ overlay_id: int,
+ *,
+ reason: str = "manual",
+ ) -> dict[str, Any]:
+ row = self.get_overlay(identity_hash, overlay_id)
+ if not row:
+ raise OverlaySourceParseError("not_found")
+ spec = OverlaySourceSpec(
+ kind=row["kind"],
+ destination_hash=row["destination_hash"],
+ path_or_repo_path=row["path_or_repo_path"],
+ ref=row.get("ref") or "HEAD",
+ group_name=row.get("group_name"),
+ repository=row.get("repository"),
+ name=row.get("name"),
+ paths=[row["path_or_repo_path"]],
+ refresh_interval_seconds=int(row.get("refresh_interval_seconds") or 0),
+ )
+ job_id = await self._start_job_for_ids(identity_hash, [overlay_id], [spec])
+ return {
+ "job_id": job_id,
+ "overlay": self.get_overlay(identity_hash, overlay_id),
+ "reason": reason,
+ }
+
+ async def _start_job_for_ids(
+ self,
+ identity_hash: str,
+ overlay_ids: list[int],
+ specs: list[OverlaySourceSpec],
+ ) -> str:
+ job_id = uuid.uuid4().hex
+ generations: dict[int, int] = {}
+ for oid in overlay_ids:
+ row = self.database.map_overlays.get_by_id(oid)
+ gen = int(row["generation"] or 0) + 1 if row else 1
+ generations[oid] = gen
+ self.database.map_overlays.update_fields(
+ oid,
+ status="fetching",
+ last_error=None,
+ job_id=job_id,
+ generation=gen,
+ )
+
+ self._jobs[job_id] = {
+ "job_id": job_id,
+ "identity_hash": identity_hash,
+ "overlay_ids": list(overlay_ids),
+ "status": "running",
+ "phase": "queued",
+ "progress": 0.0,
+ "error": None,
+ "created_at": datetime.now(UTC).isoformat(),
+ }
+
+ async def runner():
+ try:
+ await self._run_job(
+ job_id, identity_hash, overlay_ids, specs, generations
+ )
+ except Exception as exc:
+ _log.exception("overlay job %s failed", job_id)
+ self._jobs[job_id]["status"] = "error"
+ self._jobs[job_id]["error"] = str(exc)
+
+ try:
+ loop = asyncio.get_running_loop()
+ loop.create_task(runner())
+ except RuntimeError:
+ await runner()
+ return job_id
+
+ async def _run_job(
+ self,
+ job_id: str,
+ identity_hash: str,
+ overlay_ids: list[int],
+ specs: list[OverlaySourceSpec],
+ generations: dict[int, int],
+ ) -> None:
+ max_conc = self._cfg_int("map_overlay_max_concurrent_jobs")
+ if self._job_semaphore is None:
+ self._job_semaphore = asyncio.Semaphore(max_conc)
+ # Resize not supported mid-flight; new semaphore if limit changed and idle
+ async with self._job_semaphore:
+ job = self._jobs[job_id]
+ try:
+ kind = specs[0].kind if specs else None
+ if kind == KIND_NOMADNET_FILE:
+ await self._fetch_nomadnet_job(
+ job_id,
+ identity_hash,
+ overlay_ids[0],
+ specs[0],
+ generations[overlay_ids[0]],
+ )
+ elif kind == KIND_RNGIT_FILES:
+ await self._fetch_rngit_job(
+ job_id,
+ identity_hash,
+ overlay_ids,
+ specs,
+ generations,
+ )
+ else:
+ raise OverlaySourceParseError("unsupported_kind")
+ job["status"] = "success"
+ job["phase"] = "done"
+ job["progress"] = 1.0
+ except asyncio.CancelledError:
+ job["status"] = "cancelled"
+ job["error"] = "cancelled"
+ raise
+ except (
+ OverlaySourceParseError,
+ GeoValidationError,
+ RngitFetchError,
+ ) as exc:
+ code = getattr(exc, "code", str(exc))
+ job["status"] = "error"
+ job["error"] = code
+ for oid in overlay_ids:
+ if not self._generation_current(oid, generations[oid]):
+ continue
+ self._mark_error(oid, code)
+ except Exception as exc:
+ job["status"] = "error"
+ job["error"] = str(exc)
+ for oid in overlay_ids:
+ if not self._generation_current(oid, generations[oid]):
+ continue
+ self._mark_error(oid, "fetch_failed")
+
+ def _generation_current(self, overlay_id: int, generation: int) -> bool:
+ row = self.database.map_overlays.get_by_id(overlay_id)
+ return bool(row) and int(row["generation"] or 0) == generation
+
+ def _mark_error(self, overlay_id: int, code: str) -> None:
+ row = self.database.map_overlays.get_by_id(overlay_id)
+ interval = int(row["refresh_interval_seconds"] or 0) if row else 0
+ next_at = None
+ if interval > 0:
+ # backoff after failure: at least interval
+ next_at = (datetime.now(UTC) + timedelta(seconds=interval)).isoformat()
+ self.database.map_overlays.update_fields(
+ overlay_id,
+ status="error",
+ last_error=code,
+ next_refresh_at=next_at,
+ )
+
+ def _set_phase(
+ self, job_id: str, phase: str, progress: float | None = None
+ ) -> None:
+ job = self._jobs.get(job_id)
+ if not job:
+ return
+ job["phase"] = phase
+ if progress is not None:
+ job["progress"] = progress
+
+ async def _fetch_nomadnet_job(
+ self,
+ job_id: str,
+ identity_hash: str,
+ overlay_id: int,
+ spec: OverlaySourceSpec,
+ generation: int,
+ ) -> None:
+ async with self._source_lock(overlay_id):
+ await self._fetch_with_retries(
+ job_id,
+ lambda: self._download_nomadnet_once(
+ job_id, identity_hash, overlay_id, spec, generation
+ ),
+ )
+
+ async def _fetch_rngit_job(
+ self,
+ job_id: str,
+ identity_hash: str,
+ overlay_ids: list[int],
+ specs: list[OverlaySourceSpec],
+ generations: dict[int, int],
+ ) -> None:
+ # Lock all sources in id order to avoid deadlocks
+ locks = [self._source_lock(oid) for oid in sorted(overlay_ids)]
+ for lock in locks:
+ await lock.acquire()
+ try:
+ await self._fetch_with_retries(
+ job_id,
+ lambda: self._download_rngit_once(
+ job_id,
+ identity_hash,
+ overlay_ids,
+ specs,
+ generations,
+ ),
+ )
+ finally:
+ for lock in reversed(locks):
+ lock.release()
+
+ async def _fetch_with_retries(self, job_id: str, attempt_fn) -> None:
+ max_retries = self._cfg_int("map_overlay_max_retries")
+ base_delay = self._cfg_int("map_overlay_retry_delay_seconds")
+ last_exc = None
+ for attempt in range(max_retries + 1):
+ try:
+ await attempt_fn()
+ return
+ except (GeoValidationError, OverlaySourceParseError) as exc:
+ # Do not retry validation / parse errors
+ raise exc
+ except RngitFetchError as exc:
+ if exc.code in (
+ "cancelled",
+ "rngit_tools_unavailable",
+ "path_missing",
+ "path_traversal",
+ ):
+ raise
+ last_exc = exc
+ except Exception as exc:
+ last_exc = exc
+ if attempt >= max_retries:
+ break
+ delay = min(120.0, base_delay * (2**attempt))
+ delay *= 0.5 + random.random()
+ self._set_phase(job_id, "retry_wait", progress=0.0)
+ await asyncio.sleep(delay)
+ if last_exc:
+ raise last_exc
+ raise RuntimeError("fetch_failed")
+
+ async def _download_nomadnet_once(
+ self,
+ job_id: str,
+ identity_hash: str,
+ overlay_id: int,
+ spec: OverlaySourceSpec,
+ generation: int,
+ ) -> None:
+ path_timeout = self._cfg_int("map_overlay_path_timeout_seconds")
+ transfer_timeout = self._cfg_int("map_overlay_transfer_timeout_seconds")
+ job_timeout = self._cfg_int("map_overlay_job_timeout_seconds")
+
+ loop = asyncio.get_running_loop()
+ done = asyncio.Event()
+ result: dict[str, Any] = {}
+
+ def on_success(file_name: str, payload: bytes):
+ result["ok"] = True
+ result["name"] = file_name
+ result["payload"] = payload
+ loop.call_soon_threadsafe(done.set)
+
+ def on_failure(reason: str):
+ result["ok"] = False
+ result["error"] = reason
+ loop.call_soon_threadsafe(done.set)
+
+ def on_progress(p: float):
+ self._set_phase(job_id, "transferring", progress=float(p or 0))
+
+ def on_phase(phase: str):
+ self._set_phase(job_id, phase)
+
+ downloader = self._file_downloader_factory(
+ destination_hash=bytes.fromhex(spec.destination_hash),
+ page_path=spec.path_or_repo_path,
+ on_file_download_success=on_success,
+ on_file_download_failure=on_failure,
+ on_progress_update=on_progress,
+ timeout=transfer_timeout,
+ on_phase=on_phase,
+ reticulum=self.reticulum,
+ )
+ self._active_fetchers[job_id] = downloader
+ try:
+ await asyncio.wait_for(
+ downloader.download(
+ path_lookup_timeout=path_timeout,
+ link_establishment_timeout=path_timeout,
+ ),
+ timeout=job_timeout,
+ )
+ await asyncio.wait_for(done.wait(), timeout=job_timeout)
+ except TimeoutError as exc:
+ downloader.cancel()
+ raise RngitFetchError("job_timeout") from exc
+ finally:
+ self._active_fetchers.pop(job_id, None)
+
+ if not result.get("ok"):
+ raise RngitFetchError(str(result.get("error") or "request_failed"))
+
+ payload = result["payload"]
+ if not isinstance(payload, (bytes, bytearray)):
+ raise GeoValidationError("invalid_response_body")
+ await self._commit_bytes(
+ job_id,
+ identity_hash,
+ overlay_id,
+ generation,
+ bytes(payload),
+ hinted_format=guess_format_from_path(spec.path_or_repo_path),
+ resolved_ref=None,
+ refresh_interval=spec.refresh_interval_seconds,
+ )
+
+ async def _download_rngit_once(
+ self,
+ job_id: str,
+ identity_hash: str,
+ overlay_ids: list[int],
+ specs: list[OverlaySourceSpec],
+ generations: dict[int, int],
+ ) -> None:
+ job_timeout = self._cfg_int("map_overlay_job_timeout_seconds")
+ first = specs[0]
+ paths = [s.path_or_repo_path for s in specs]
+ fetcher = self._rngit_fetcher_factory(
+ work_root=self.work_root(),
+ reticulum_config_dir=self.reticulum_config_dir,
+ )
+ self._active_fetchers[job_id] = fetcher
+
+ def on_phase(phase: str):
+ self._set_phase(job_id, phase)
+
+ try:
+ result = await asyncio.wait_for(
+ fetcher.fetch(
+ destination_hash=first.destination_hash,
+ group=first.group_name,
+ repository=first.repository,
+ paths=paths,
+ ref=first.ref,
+ job_id=job_id,
+ timeout_seconds=job_timeout,
+ on_phase=on_phase,
+ ),
+ timeout=job_timeout + 5,
+ )
+ finally:
+ self._active_fetchers.pop(job_id, None)
+
+ for oid, spec in zip(overlay_ids, specs, strict=True):
+ if not self._generation_current(oid, generations[oid]):
+ continue
+ payload = result.files.get(spec.path_or_repo_path)
+ if payload is None:
+ self._mark_error(oid, "path_missing")
+ continue
+ await self._commit_bytes(
+ job_id,
+ identity_hash,
+ oid,
+ generations[oid],
+ payload,
+ hinted_format=guess_format_from_path(spec.path_or_repo_path),
+ resolved_ref=result.resolved_ref,
+ refresh_interval=spec.refresh_interval_seconds,
+ )
+
+ async def _commit_bytes(
+ self,
+ job_id: str,
+ identity_hash: str,
+ overlay_id: int,
+ generation: int,
+ payload: bytes,
+ *,
+ hinted_format: str | None,
+ resolved_ref: str | None,
+ refresh_interval: int,
+ ) -> None:
+ if not self._generation_current(overlay_id, generation):
+ return
+ limits = self.limits()
+ self._set_phase(job_id, "validating")
+ validated = validate_geo_bytes(
+ payload,
+ hinted_format=hinted_format,
+ max_bytes=limits["map_overlay_max_bytes"],
+ max_features=limits["map_overlay_max_features"],
+ max_kmz_uncompressed_bytes=limits["map_overlay_max_kmz_uncompressed_bytes"],
+ )
+ digest = hashlib.sha256(payload).hexdigest()
+ row = self.database.map_overlays.get_by_id(overlay_id)
+ if row and row.get("content_sha256") == digest and row.get("cache_relpath"):
+ abs_existing = os.path.join(self.overlay_root(), row["cache_relpath"])
+ if os.path.isfile(abs_existing):
+ now = datetime.now(UTC)
+ next_at = None
+ if refresh_interval > 0:
+ next_at = (now + timedelta(seconds=refresh_interval)).isoformat()
+ self.database.map_overlays.update_fields(
+ overlay_id,
+ status="ready",
+ last_error=None,
+ last_fetched_at=now.isoformat(),
+ next_refresh_at=next_at,
+ resolved_ref=resolved_ref or row.get("resolved_ref"),
+ format=validated.format,
+ byte_size=validated.byte_size,
+ )
+ return
+
+ abs_path, rel = self.cache_path_for(identity_hash, overlay_id, validated.format)
+ atomic_write_bytes(abs_path, payload)
+ now = datetime.now(UTC)
+ next_at = None
+ if refresh_interval > 0:
+ next_at = (now + timedelta(seconds=refresh_interval)).isoformat()
+ if not self._generation_current(overlay_id, generation):
+ return
+ self.database.map_overlays.update_fields(
+ overlay_id,
+ status="ready",
+ last_error=None,
+ last_fetched_at=now.isoformat(),
+ next_refresh_at=next_at,
+ content_sha256=digest,
+ resolved_ref=resolved_ref,
+ format=validated.format,
+ byte_size=validated.byte_size,
+ cache_relpath=rel,
+ )
+
+ def cancel_job(self, job_id: str) -> bool:
+ job = self._jobs.get(job_id)
+ if not job or job.get("status") not in ("running",):
+ return False
+ fetcher = self._active_fetchers.get(job_id)
+ if fetcher is not None:
+ try:
+ fetcher.cancel()
+ except Exception:
+ pass
+ job["status"] = "cancelled"
+ job["error"] = "cancelled"
+ for oid in job.get("overlay_ids") or []:
+ row = self.database.map_overlays.get_by_id(oid)
+ if row and row.get("job_id") == job_id and row.get("status") == "fetching":
+ self.database.map_overlays.update_fields(
+ oid,
+ status="error",
+ last_error="cancelled",
+ )
+ return True
+
+ def patch_overlay(
+ self,
+ identity_hash: str,
+ overlay_id: int,
+ data: dict[str, Any],
+ ) -> dict[str, Any]:
+ row = self.get_overlay(identity_hash, overlay_id)
+ if not row:
+ raise OverlaySourceParseError("not_found")
+ fields: dict[str, Any] = {}
+ if "name" in data and data["name"] is not None:
+ fields["name"] = str(data["name"]).strip()[:200] or row["name"]
+ if "enabled" in data:
+ fields["enabled"] = 1 if data["enabled"] else 0
+ if "visible" in data:
+ fields["visible"] = 1 if data["visible"] else 0
+ if "refresh_interval_seconds" in data:
+ try:
+ ri = int(data["refresh_interval_seconds"])
+ except (TypeError, ValueError) as exc:
+ raise OverlaySourceParseError("invalid_refresh_interval") from exc
+ if ri < 0:
+ raise OverlaySourceParseError("invalid_refresh_interval")
+ if 0 < ri < 60:
+ ri = 60
+ if ri > 86400:
+ ri = 86400
+ fields["refresh_interval_seconds"] = ri
+ if ri > 0:
+ base = row.get("last_fetched_at")
+ if base:
+ fields["next_refresh_at"] = (
+ datetime.fromisoformat(str(base).replace("Z", "+00:00"))
+ + timedelta(seconds=ri)
+ ).isoformat()
+ else:
+ fields["next_refresh_at"] = datetime.now(UTC).isoformat()
+ else:
+ fields["next_refresh_at"] = None
+ if "ref" in data and row["kind"] == KIND_RNGIT_FILES:
+ from meshchatx.src.backend.map_overlay_sources import normalize_ref
+
+ fields["ref"] = normalize_ref(data["ref"])
+ if fields:
+ self.database.map_overlays.update_fields(overlay_id, **fields)
+ return self.get_overlay(identity_hash, overlay_id)
+
+ def delete_overlay(self, identity_hash: str, overlay_id: int) -> bool:
+ row = self.get_overlay(identity_hash, overlay_id)
+ if not row:
+ return False
+ rel = row.get("cache_relpath")
+ if rel:
+ abs_path = os.path.join(self.overlay_root(), rel)
+ try:
+ if os.path.isfile(abs_path):
+ os.remove(abs_path)
+ except OSError:
+ pass
+ return self.database.map_overlays.delete_for_identity(identity_hash, overlay_id)
+
+ def read_cache_bytes(
+ self, identity_hash: str, overlay_id: int
+ ) -> tuple[bytes, str] | None:
+ row = self.get_overlay(identity_hash, overlay_id)
+ if not row or not row.get("cache_relpath") or not row.get("format"):
+ return None
+ abs_path = os.path.join(self.overlay_root(), row["cache_relpath"])
+ if not os.path.isfile(abs_path):
+ return None
+ with open(abs_path, "rb") as f:
+ data = f.read()
+ return data, row["format"]
+
+ def export_overlay(
+ self,
+ identity_hash: str,
+ overlay_id: int,
+ target_format: str,
+ ) -> tuple[bytes, str, str]:
+ cached = self.read_cache_bytes(identity_hash, overlay_id)
+ if not cached:
+ raise OverlayExportError("cache_missing")
+ data, src_fmt = cached
+ limits = self.limits()
+ out = convert_overlay_bytes(
+ data,
+ source_format=src_fmt,
+ target_format=target_format,
+ max_bytes=limits["map_overlay_max_bytes"],
+ max_features=limits["map_overlay_max_features"],
+ max_kmz_uncompressed_bytes=limits["map_overlay_max_kmz_uncompressed_bytes"],
+ )
+ row = self.get_overlay(identity_hash, overlay_id)
+ filename = _safe_filename(
+ row.get("name") if row else "overlay", EXTENSIONS[target_format]
+ )
+ return out, CONTENT_TYPES[target_format], filename
+
+ def export_many(
+ self,
+ identity_hash: str,
+ overlay_ids: list[int],
+ target_format: str,
+ ) -> tuple[bytes, str, str]:
+ if not overlay_ids:
+ raise OverlayExportError("missing_ids")
+ if len(overlay_ids) == 1:
+ return self.export_overlay(identity_hash, overlay_ids[0], target_format)
+ geo_chunks: list[bytes] = []
+ limits = self.limits()
+ for oid in overlay_ids:
+ cached = self.read_cache_bytes(identity_hash, oid)
+ if not cached:
+ raise OverlayExportError("cache_missing")
+ data, src_fmt = cached
+ geo_chunks.append(to_geojson(data, src_fmt))
+ merged = merge_geojson_bytes(geo_chunks)
+ out = from_geojson(merged, target_format)
+ max_bytes = limits["map_overlay_max_bytes"] * max(1, len(overlay_ids))
+ max_features = limits["map_overlay_max_features"] * max(1, len(overlay_ids))
+ max_uncomp = limits["map_overlay_max_kmz_uncompressed_bytes"] * max(
+ 1,
+ len(overlay_ids),
+ )
+ if len(out) > max_bytes:
+ raise OverlayExportError("file_too_large")
+ validate_geo_bytes(
+ out,
+ hinted_format=target_format,
+ max_bytes=max_bytes,
+ max_features=max_features,
+ max_kmz_uncompressed_bytes=max_uncomp,
+ )
+ filename = _safe_filename("overlays", EXTENSIONS[target_format])
+ return out, CONTENT_TYPES[target_format], filename
+
+ def cleanup(self) -> None:
+ self.stop_scheduler()
+ work = os.path.join(self.overlay_root(), ".work")
+ if os.path.isdir(work):
+ shutil.rmtree(work, ignore_errors=True)
diff --git a/meshchatx/src/backend/map_overlay_sources.py b/meshchatx/src/backend/map_overlay_sources.py
new file mode 100644
index 00000000..e6dffefc
--- /dev/null
+++ b/meshchatx/src/backend/map_overlay_sources.py
@@ -0,0 +1,271 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Parse and validate NomadNet / RNGit map overlay source descriptors."""
+
+from __future__ import annotations
+
+import os
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+import RNS
+
+KIND_NOMADNET_FILE = "nomadnet_file"
+KIND_RNGIT_FILES = "rngit_files"
+
+_HASH_HEX_LEN = RNS.Reticulum.TRUNCATED_HASHLENGTH // 4
+_HASH_RE = re.compile(rf"^[0-9a-fA-F]{{{_HASH_HEX_LEN}}}$")
+_SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
+_REF_RE = re.compile(r"^[A-Za-z0-9._/@+-]{1,256}$")
+_COMMIT_LIKE_RE = re.compile(r"^[0-9a-fA-F]{7,40}$")
+
+
+class OverlaySourceParseError(ValueError):
+ def __init__(self, code: str, message: str | None = None):
+ self.code = code
+ super().__init__(message or code)
+
+
+@dataclass
+class OverlaySourceSpec:
+ kind: str
+ destination_hash: str
+ path_or_repo_path: str
+ ref: str = "HEAD"
+ group_name: str | None = None
+ repository: str | None = None
+ name: str | None = None
+ paths: list[str] = field(default_factory=list)
+ refresh_interval_seconds: int = 0
+
+ def unique_key(self) -> tuple[str, str, str, str]:
+ return (
+ self.kind,
+ self.destination_hash,
+ self.path_or_repo_path,
+ self.ref,
+ )
+
+
+def normalize_destination_hash_hex(value: str) -> str | None:
+ if not isinstance(value, str):
+ return None
+ raw = value.strip().lower().replace(":", "")
+ if not _HASH_RE.fullmatch(raw):
+ return None
+ try:
+ bytes.fromhex(raw)
+ except ValueError:
+ return None
+ return raw
+
+
+def slug_segment(name: str) -> str | None:
+ if not isinstance(name, str):
+ return None
+ s = name.strip()
+ if not s or len(s) > 256 or not _SLUG_RE.fullmatch(s):
+ return None
+ return s
+
+
+def normalize_ref(ref: str | None) -> str:
+ if ref is None or str(ref).strip() == "":
+ return "HEAD"
+ s = str(ref).strip()
+ if not _REF_RE.fullmatch(s):
+ raise OverlaySourceParseError("invalid_ref")
+ if ".." in s or s.startswith("-"):
+ raise OverlaySourceParseError("invalid_ref")
+ return s
+
+
+def is_commit_like_ref(ref: str) -> bool:
+ return bool(_COMMIT_LIKE_RE.fullmatch(ref))
+
+
+def _safe_repo_relpath(path: str) -> str:
+ if not isinstance(path, str):
+ raise OverlaySourceParseError("invalid_path")
+ p = path.strip().replace("\\", "/")
+ if not p or p.startswith("/") or p.startswith("~"):
+ raise OverlaySourceParseError("invalid_path")
+ parts = [seg for seg in p.split("/") if seg and seg != "."]
+ if not parts or any(seg == ".." for seg in parts):
+ raise OverlaySourceParseError("path_traversal")
+ joined = "/".join(parts)
+ if len(joined) > 1024:
+ raise OverlaySourceParseError("path_too_long")
+ return joined
+
+
+def _safe_nomadnet_file_path(path: str) -> str:
+ if not isinstance(path, str):
+ raise OverlaySourceParseError("invalid_path")
+ p = path.strip().replace("\\", "/")
+ if p.startswith("file/"):
+ p = "/" + p
+ if not p.startswith("/file/"):
+ raise OverlaySourceParseError("not_file_path")
+ rest = p[len("/file/") :]
+ if not rest or rest.endswith("/"):
+ raise OverlaySourceParseError("invalid_path")
+ parts = [seg for seg in rest.split("/") if seg and seg != "."]
+ if not parts or any(seg == ".." for seg in parts):
+ raise OverlaySourceParseError("path_traversal")
+ return "/file/" + "/".join(parts)
+
+
+def _default_name_from_path(path: str) -> str:
+ base = os.path.basename(path.rstrip("/"))
+ return base or "overlay"
+
+
+def parse_nomadnet_file_url(url: str) -> OverlaySourceSpec:
+ if not isinstance(url, str) or not url.strip():
+ raise OverlaySourceParseError("empty_url")
+ raw = url.strip()
+ for prefix in ("nomadnet://", "nomadnetwork://"):
+ if raw.lower().startswith(prefix):
+ raw = raw[len(prefix) :]
+ break
+ if ":" in raw:
+ hash_part, path_part = raw.split(":", 1)
+ elif "/file/" in raw:
+ idx = raw.lower().find("/file/")
+ hash_part, path_part = raw[:idx], raw[idx:]
+ else:
+ raise OverlaySourceParseError("invalid_nomadnet_url")
+ dest = normalize_destination_hash_hex(hash_part)
+ if not dest:
+ raise OverlaySourceParseError("invalid_destination_hash")
+ file_path = _safe_nomadnet_file_path(path_part)
+ return OverlaySourceSpec(
+ kind=KIND_NOMADNET_FILE,
+ destination_hash=dest,
+ path_or_repo_path=file_path,
+ ref="HEAD",
+ name=_default_name_from_path(file_path),
+ paths=[file_path],
+ )
+
+
+def parse_rngit_repo_url(url: str) -> tuple[str, str, str]:
+ if not isinstance(url, str) or not url.strip():
+ raise OverlaySourceParseError("empty_url")
+ raw = url.strip()
+ if raw.lower().startswith("rns://"):
+ raw = raw[6:]
+ parts = [p for p in raw.split("/") if p]
+ if len(parts) < 3:
+ raise OverlaySourceParseError("invalid_rngit_url")
+ dest = normalize_destination_hash_hex(parts[0])
+ if not dest:
+ raise OverlaySourceParseError("invalid_destination_hash")
+ group = slug_segment(parts[1])
+ repo = slug_segment(parts[2])
+ if not group or not repo:
+ raise OverlaySourceParseError("invalid_repository")
+ return dest, group, repo
+
+
+def _clamp_refresh_interval(value: Any) -> int:
+ try:
+ refresh_i = int(value)
+ except (TypeError, ValueError) as exc:
+ raise OverlaySourceParseError("invalid_refresh_interval") from exc
+ if refresh_i < 0:
+ raise OverlaySourceParseError("invalid_refresh_interval")
+ if 0 < refresh_i < 60:
+ return 60
+ if refresh_i > 86400:
+ return 86400
+ return refresh_i
+
+
+def _looks_like_nomadnet(url: str) -> bool:
+ u = url.strip().lower()
+ return (
+ u.startswith("nomadnet://")
+ or u.startswith("nomadnetwork://")
+ or ":/file/" in u
+ or (len(u) > _HASH_HEX_LEN and "/file/" in u)
+ )
+
+
+def parse_create_payload(data: dict[str, Any]) -> list[OverlaySourceSpec]:
+ """Parse POST /api/v1/map/overlays body into one or more source specs."""
+ if not isinstance(data, dict):
+ raise OverlaySourceParseError("invalid_body")
+
+ kind = (data.get("kind") or "").strip().lower()
+ url = str(data.get("url") or data.get("source") or "").strip()
+ if not url:
+ raise OverlaySourceParseError("empty_url")
+
+ refresh_i = _clamp_refresh_interval(data.get("refresh_interval_seconds", 0))
+ name_override = data.get("name")
+ if name_override is not None:
+ name_override = str(name_override).strip()[:200] or None
+
+ use_nomadnet = kind in ("nomadnet", "nomadnet_file") or (
+ kind in ("",)
+ and _looks_like_nomadnet(url)
+ and not url.lower().startswith("rns://")
+ )
+ use_rngit = kind in ("rngit", "rngit_files") or url.lower().startswith("rns://")
+
+ if use_nomadnet and not use_rngit:
+ spec = parse_nomadnet_file_url(url)
+ if name_override:
+ spec.name = name_override
+ spec.refresh_interval_seconds = refresh_i
+ return [spec]
+
+ if use_rngit:
+ dest, group, repo = parse_rngit_repo_url(url)
+ ref = normalize_ref(data.get("ref"))
+ paths_raw = data.get("paths") or data.get("files") or []
+ if isinstance(paths_raw, str):
+ paths_raw = [
+ line.strip() for line in paths_raw.splitlines() if line.strip()
+ ]
+ if not isinstance(paths_raw, list) or not paths_raw:
+ raise OverlaySourceParseError("missing_paths")
+ if len(paths_raw) > 32:
+ raise OverlaySourceParseError("too_many_paths")
+ specs: list[OverlaySourceSpec] = []
+ for raw_path in paths_raw:
+ rel = _safe_repo_relpath(str(raw_path))
+ lower = rel.lower()
+ if not lower.endswith((".geojson", ".json", ".kml", ".kmz")):
+ raise OverlaySourceParseError("unsupported_extension")
+ nm = name_override or _default_name_from_path(rel)
+ specs.append(
+ OverlaySourceSpec(
+ kind=KIND_RNGIT_FILES,
+ destination_hash=dest,
+ path_or_repo_path=rel,
+ ref=ref,
+ group_name=group,
+ repository=repo,
+ name=nm,
+ paths=[rel],
+ refresh_interval_seconds=refresh_i,
+ ),
+ )
+ return specs
+
+ raise OverlaySourceParseError("unsupported_kind")
+
+
+def guess_format_from_path(path: str) -> str | None:
+ lower = path.lower()
+ if lower.endswith(".kmz"):
+ return "kmz"
+ if lower.endswith(".kml"):
+ return "kml"
+ if lower.endswith(".geojson") or lower.endswith(".json"):
+ return "geojson"
+ return None
diff --git a/meshchatx/src/backend/memory_pressure.py b/meshchatx/src/backend/memory_pressure.py
index 46baf94c..709bc9e4 100644
--- a/meshchatx/src/backend/memory_pressure.py
+++ b/meshchatx/src/backend/memory_pressure.py
@@ -92,9 +92,16 @@ class MemoryPressureManager:
db = getattr(self.app, "database", None) if self.app else None
if db is not None and hasattr(db, "apply_memory_pressure_pragmas"):
try:
- db.apply_memory_pressure_pragmas(True)
+ landlock_active = bool(
+ getattr(self.app, "landlock_active", False),
+ )
+ db.apply_memory_pressure_pragmas(
+ True,
+ landlock_active=landlock_active,
+ )
self._sqlite_relaxed = True
stats["sqlite_relaxed"] = True
+ stats["sqlite_file_temp"] = not landlock_active
except Exception as exc:
_log.debug("SQLite pressure pragmas failed: %s", exc)
_log.warning(
diff --git a/meshchatx/src/backend/message_handler.py b/meshchatx/src/backend/message_handler.py
index 10072df2..ace962f6 100644
--- a/meshchatx/src/backend/message_handler.py
+++ b/meshchatx/src/backend/message_handler.py
@@ -71,6 +71,33 @@ class MessageHandler:
params = [like_term, like_term, like_term, limit]
return self.db.provider.fetchall(query, params)
+ # Keep conversation-list payloads small. Full ``fields`` often embeds
+ # multi-MB base64 attachments and must never be loaded into the list API.
+ _CONVERSATION_CONTENT_PREVIEW_CHARS = 240
+ _FIELDS_HAS_IMAGE_SQL = (
+ "(m1.fields IS NOT NULL AND m1.fields != '' AND m1.fields != '{}' "
+ "AND (instr(m1.fields, '\"image\"') > 0 OR instr(m1.fields, '\"0x05\"') > 0))"
+ )
+ _FIELDS_HAS_AUDIO_SQL = (
+ "(m1.fields IS NOT NULL AND m1.fields != '' AND m1.fields != '{}' "
+ "AND (instr(m1.fields, '\"audio\"') > 0 OR instr(m1.fields, '\"0x06\"') > 0))"
+ )
+ _FIELDS_HAS_FILES_SQL = (
+ "(m1.fields IS NOT NULL AND m1.fields != '' AND m1.fields != '{}' "
+ "AND (instr(m1.fields, '\"file_attachments\"') > 0 "
+ "OR instr(m1.fields, '\"0x07\"') > 0))"
+ )
+ _FIELDS_HAS_REACTION_SQL = (
+ "(m1.fields IS NOT NULL AND m1.fields != '' AND m1.fields != '{}' "
+ "AND (instr(m1.fields, '\"reaction\"') > 0 OR instr(m1.fields, '\"0x40\"') > 0))"
+ )
+ _FIELDS_HAS_TELEMETRY_SQL = (
+ "(m1.fields IS NOT NULL AND m1.fields != '' AND m1.fields != '{}' "
+ "AND (instr(m1.fields, '\"telemetry\"') > 0 OR instr(m1.fields, '\"0x08\"') > 0 "
+ "OR instr(m1.fields, '\"telemetry_stream\"') > 0))"
+ )
+ _FIELDS_HAS_ATTACHMENTS_SQL = f"({_FIELDS_HAS_IMAGE_SQL} OR {_FIELDS_HAS_AUDIO_SQL} OR {_FIELDS_HAS_FILES_SQL})"
+
def get_conversations(
self,
local_hash,
@@ -82,13 +109,22 @@ class MessageHandler:
limit=500,
offset=0,
):
- query = """
+ preview_chars = self._CONVERSATION_CONTENT_PREVIEW_CHARS
+ query = f"""
SELECT
m1.id, m1.hash, m1.source_hash, m1.destination_hash,
m1.peer_hash, m1.state, m1.progress, m1.is_incoming,
- m1.title, m1.content, m1.fields, m1.timestamp,
+ m1.title,
+ substr(COALESCE(m1.content, ''), 1, {preview_chars}) as content,
+ m1.timestamp,
m1.is_spam, m1.reply_to_hash,
m1.created_at, m1.updated_at,
+ CASE WHEN {self._FIELDS_HAS_IMAGE_SQL} THEN 1 ELSE 0 END as has_image,
+ CASE WHEN {self._FIELDS_HAS_AUDIO_SQL} THEN 1 ELSE 0 END as has_audio,
+ CASE WHEN {self._FIELDS_HAS_FILES_SQL} THEN 1 ELSE 0 END as has_files,
+ CASE WHEN {self._FIELDS_HAS_REACTION_SQL} THEN 1 ELSE 0 END as has_reaction,
+ CASE WHEN {self._FIELDS_HAS_TELEMETRY_SQL} THEN 1 ELSE 0 END as has_telemetry,
+ CASE WHEN {self._FIELDS_HAS_ATTACHMENTS_SQL} THEN 1 ELSE 0 END as has_attachments,
a.app_data as peer_app_data,
c.display_name as custom_display_name,
con.custom_image as contact_image,
@@ -139,9 +175,7 @@ class MessageHandler:
where_clauses.append("m1.state = 'failed'")
if filter_has_attachments:
- where_clauses.append(
- "(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')",
- )
+ where_clauses.append(self._FIELDS_HAS_ATTACHMENTS_SQL)
if search:
search = _strip_utf16_surrogates(search) or ""
diff --git a/meshchatx/src/backend/repository_server_manager.py b/meshchatx/src/backend/repository_server_manager.py
index 9754764c..4ce4fc67 100644
--- a/meshchatx/src/backend/repository_server_manager.py
+++ b/meshchatx/src/backend/repository_server_manager.py
@@ -432,6 +432,7 @@ class RepositoryServerManager:
"completed": 0,
"total": 0,
}
+ self._refresh_lock = threading.Lock()
os.makedirs(self.uploads_dir, exist_ok=True)
os.makedirs(self.bundled_dir, exist_ok=True)
self._seed_bundled_from_public()
@@ -661,18 +662,32 @@ class RepositoryServerManager:
}
def refresh_bundled_wheels(self) -> dict[str, Any]:
- """Download wheels into ``bundled_dir`` (PyPI JSON + ``urllib``)."""
+ """Download wheels into ``bundled_dir`` (PyPI JSON + ``urllib``).
+
+ Downloads into a temporary directory first, then atomically replaces
+ the live bundled directory so a failed refresh cannot wipe existing
+ wheels. Concurrent refreshes are rejected.
+ """
+ if not self._refresh_lock.acquire(blocking=False):
+ return {
+ "ok": False,
+ "downloaded": [],
+ "failed": {},
+ "error": "refresh_already_running",
+ }
+
self._last_refresh_error = None
self._last_refresh_ok = []
self._last_refresh_failed = {}
dest = Path(self.bundled_dir)
dest.mkdir(parents=True, exist_ok=True)
- for old in dest.glob("*.whl"):
- try:
- old.unlink()
- except OSError:
- pass
+ staging = (
+ dest.parent / f".bundled-refresh-{os.getpid()}-{threading.get_ident()}"
+ )
+ if staging.exists():
+ shutil.rmtree(staging, ignore_errors=True)
+ staging.mkdir(parents=True, exist_ok=True)
total = len(bundled_pip_targets())
ok: list[str] = []
@@ -685,9 +700,42 @@ class RepositoryServerManager:
running=True, current=pkg, completed=i, total=t
)
- result = download_bundled_wheels_to_directory(dest, on_package=_on_pkg)
+ result = download_bundled_wheels_to_directory(staging, on_package=_on_pkg)
ok = result["downloaded"]
failed = result["failed"]
+ if ok:
+ backup = (
+ dest.parent
+ / f".bundled-backup-{os.getpid()}-{threading.get_ident()}"
+ )
+ if backup.exists():
+ shutil.rmtree(backup, ignore_errors=True)
+ try:
+ os.replace(dest, backup)
+ except OSError:
+ shutil.move(str(dest), str(backup))
+ try:
+ try:
+ os.replace(staging, dest)
+ except OSError:
+ shutil.move(str(staging), str(dest))
+ except Exception:
+ # Restore previous wheels if the staged swap fails.
+ if not dest.exists() and backup.exists():
+ try:
+ os.replace(backup, dest)
+ except OSError:
+ shutil.move(str(backup), str(dest))
+ raise
+ else:
+ shutil.rmtree(backup, ignore_errors=True)
+ else:
+ shutil.rmtree(staging, ignore_errors=True)
+ except Exception as exc:
+ shutil.rmtree(staging, ignore_errors=True)
+ failed = {"refresh": str(exc)}
+ self._last_refresh_error = str(exc)
+ ok = []
finally:
self._set_refresh_progress(
running=False,
@@ -695,16 +743,21 @@ class RepositoryServerManager:
completed=total,
total=total,
)
+ self._refresh_lock.release()
self._last_refresh_ok = ok
self._last_refresh_failed = failed
- if not ok and failed:
- self._last_refresh_error = "all_downloads_failed"
- elif failed:
- self._last_refresh_error = "partial_failure"
+ if self._last_refresh_error is None:
+ if not ok and failed:
+ self._last_refresh_error = "all_downloads_failed"
+ elif failed:
+ self._last_refresh_error = "partial_failure"
- return {
+ payload = {
"ok": bool(ok),
"downloaded": ok,
"failed": failed,
}
+ if self._last_refresh_error and not ok:
+ payload["error"] = self._last_refresh_error
+ return payload
diff --git a/meshchatx/src/backend/reticulum_config_guard.py b/meshchatx/src/backend/reticulum_config_guard.py
index 7fff7ea8..85299b8d 100644
--- a/meshchatx/src/backend/reticulum_config_guard.py
+++ b/meshchatx/src/backend/reticulum_config_guard.py
@@ -81,3 +81,16 @@ def repair_unparseable_reticulum_config(config_path: str, *, write_default) -> b
write_default(config_path)
return True
+
+
+def ensure_safe_reticulum_runtime_flags(config_path: str) -> bool:
+ """Force runtime flags that keep MeshChatX alive when interfaces fail.
+
+ Currently forces ``panic_on_interface_error = No`` so RNS does not call
+ ``os._exit`` on interface faults.
+ """
+ from meshchatx.src.backend.rns_startup_recovery import (
+ ensure_panic_on_interface_error_disabled,
+ )
+
+ return ensure_panic_on_interface_error_disabled(config_path)
diff --git a/meshchatx/src/backend/rncp_handler.py b/meshchatx/src/backend/rncp_handler.py
index b9c2bbd8..0d05c78f 100644
--- a/meshchatx/src/backend/rncp_handler.py
+++ b/meshchatx/src/backend/rncp_handler.py
@@ -27,6 +27,7 @@ class RNCPHandler:
self._listener_fetch_registered = False
self._listener_fetch_allowed = False
self.on_receive_completed = None
+ self._cancelled_transfers: set[str] = set()
def _emit_receive_event(self, payload):
if self.on_receive_completed:
@@ -35,6 +36,30 @@ class RNCPHandler:
except Exception:
pass
+ def _default_fetch_save_dir(self) -> str:
+ path = os.path.join(self.storage_dir, "rncp", "downloads")
+ os.makedirs(path, exist_ok=True)
+ return path
+
+ def cancel_transfer(self, transfer_id: str | None = None) -> dict:
+ """Mark one or all active transfers as cancelled."""
+ if transfer_id:
+ self._cancelled_transfers.add(transfer_id)
+ transfer = self.active_transfers.get(transfer_id)
+ if transfer is not None:
+ transfer["status"] = "cancelled"
+ return {"cancelled": [transfer_id]}
+ ids = list(self.active_transfers.keys())
+ for tid in ids:
+ self._cancelled_transfers.add(tid)
+ self.active_transfers[tid]["status"] = "cancelled"
+ return {"cancelled": ids}
+
+ def _is_cancelled(self, transfer_id: str | None) -> bool:
+ if transfer_id and transfer_id in self._cancelled_transfers:
+ return True
+ return False
+
def teardown_receive_destination(self):
if self.receive_destination is None:
self.allowed_identity_hashes = []
@@ -351,6 +376,13 @@ class RNCPHandler:
pass
while resource.status < RNS.Resource.COMPLETE:
+ if self._is_cancelled(transfer_id):
+ with contextlib.suppress(Exception):
+ link.teardown()
+ if transfer_id in self.active_transfers:
+ self.active_transfers[transfer_id]["status"] = "cancelled"
+ msg = "Transfer cancelled"
+ raise InterruptedError(msg)
await asyncio.sleep(0.1)
if resource.status > RNS.Resource.COMPLETE:
msg = "File was not accepted by destination"
@@ -455,51 +487,54 @@ class RNCPHandler:
resource_status = "started"
saved_filename = None
+ save_error = None
+ effective_save_path = (
+ os.path.abspath(os.path.expanduser(save_path))
+ if isinstance(save_path, str) and save_path.strip()
+ else self._default_fetch_save_dir()
+ )
def fetch_resource_concluded(resource):
- nonlocal resource_resolved, resource_status, saved_filename
- if resource.status == RNS.Resource.COMPLETE:
- if resource.metadata:
- try:
- filename = os.path.basename(
- resource.metadata["name"].decode("utf-8"),
- )
- if save_path:
- save_dir = os.path.abspath(os.path.expanduser(save_path))
+ nonlocal resource_resolved, resource_status, saved_filename, save_error
+ try:
+ if resource.status == RNS.Resource.COMPLETE:
+ if resource.metadata:
+ try:
+ filename = os.path.basename(
+ resource.metadata["name"].decode("utf-8"),
+ )
+ save_dir = effective_save_path
os.makedirs(save_dir, exist_ok=True)
saved_filename = os.path.join(save_dir, filename)
- else:
- saved_filename = filename
-
- counter = 0
- if allow_overwrite:
- if os.path.isfile(saved_filename):
- try:
- os.unlink(saved_filename)
- except OSError:
- # Failed to delete existing file, which is fine,
- # we'll just fall through to the naming loop
- pass
-
- while os.path.isfile(saved_filename):
- counter += 1
- base, ext = os.path.splitext(filename)
- saved_filename = os.path.join(
- os.path.dirname(saved_filename) if save_path else ".",
- f"{base}.{counter}{ext}",
- )
- shutil.move(resource.data.name, saved_filename)
- resource_status = "completed"
- except Exception as e:
+ counter = 0
+ if allow_overwrite:
+ if os.path.isfile(saved_filename):
+ try:
+ os.unlink(saved_filename)
+ except OSError:
+ pass
+
+ while os.path.isfile(saved_filename):
+ counter += 1
+ base, ext = os.path.splitext(filename)
+ saved_filename = os.path.join(
+ save_dir,
+ f"{base}.{counter}{ext}",
+ )
+
+ shutil.move(resource.data.name, saved_filename)
+ resource_status = "completed"
+ except Exception as e:
+ resource_status = "error"
+ save_error = str(e)
+ else:
resource_status = "error"
- raise e
+ save_error = "missing resource metadata"
else:
- resource_status = "error"
- else:
- resource_status = "failed"
-
- resource_resolved = True
+ resource_status = "failed"
+ finally:
+ resource_resolved = True
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_started_callback(fetch_resource_started)
@@ -532,6 +567,13 @@ class RNCPHandler:
raise Exception(msg)
while not resource_resolved:
+ if current_resource is not None and hasattr(current_resource, "hash"):
+ tid = getattr(current_resource, "hash", None)
+ if tid is not None and self._is_cancelled(tid.hex()):
+ with contextlib.suppress(Exception):
+ link.teardown()
+ msg = "Transfer cancelled"
+ raise InterruptedError(msg)
await asyncio.sleep(0.1)
if resource_status == "completed":
@@ -541,7 +583,10 @@ class RNCPHandler:
"file_path": saved_filename,
}
link.teardown()
- msg = f"Transfer failed: {resource_status}"
+ if save_error:
+ msg = f"Transfer failed: {resource_status}: {save_error}"
+ else:
+ msg = f"Transfer failed: {resource_status}"
raise Exception(msg)
def get_transfer_status(self, transfer_id: str):
diff --git a/meshchatx/src/backend/rngit_sparse_fetcher.py b/meshchatx/src/backend/rngit_sparse_fetcher.py
new file mode 100644
index 00000000..d043f110
--- /dev/null
+++ b/meshchatx/src/backend/rngit_sparse_fetcher.py
@@ -0,0 +1,268 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Sparse fetch of specific files from an RNGit ``rns://`` repository."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import shutil
+import signal
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+from meshchatx.src.backend.map_overlay_sources import is_commit_like_ref
+
+
+class RngitFetchError(RuntimeError):
+ def __init__(self, code: str, message: str | None = None):
+ self.code = code
+ super().__init__(message or code)
+
+
+@dataclass
+class RngitFetchResult:
+ files: dict[str, bytes]
+ resolved_ref: str
+
+
+def tools_available(
+ *,
+ which: Callable[[str], str | None] | None = None,
+) -> tuple[bool, str | None]:
+ finder = which or shutil.which
+ if not finder("git"):
+ return False, "git_missing"
+ if not finder("git-remote-rns"):
+ return False, "git_remote_rns_missing"
+ return True, None
+
+
+async def _run_git(
+ args: list[str],
+ *,
+ cwd: str | None,
+ env: dict[str, str],
+ timeout: float,
+ processes: list,
+) -> tuple[int, bytes, bytes]:
+ proc = await asyncio.create_subprocess_exec(
+ *args,
+ cwd=cwd,
+ env=env,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ start_new_session=True,
+ )
+ processes.append(proc)
+ try:
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except TimeoutError as exc:
+ _kill_process_group(proc)
+ raise RngitFetchError("git_timeout") from exc
+ return proc.returncode or 0, stdout or b"", stderr or b""
+
+
+def _kill_process_group(proc) -> None:
+ try:
+ if proc.returncode is None:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except Exception:
+ try:
+ proc.kill()
+ except Exception:
+ pass
+
+
+class RngitSparseFetcher:
+ def __init__(
+ self,
+ *,
+ work_root: str,
+ reticulum_config_dir: str | None,
+ rngit_config_dir: str | None = None,
+ which: Callable[[str], str | None] | None = None,
+ ):
+ self.work_root = work_root
+ self.reticulum_config_dir = reticulum_config_dir
+ self.rngit_config_dir = rngit_config_dir
+ self._which = which or shutil.which
+ self._cancelled = False
+ self._processes: list = []
+
+ def cancel(self) -> None:
+ self._cancelled = True
+ for proc in list(self._processes):
+ _kill_process_group(proc)
+
+ def _check_cancelled(self) -> None:
+ if self._cancelled:
+ raise RngitFetchError("cancelled")
+
+ def _build_env(self) -> dict[str, str]:
+ env = os.environ.copy()
+ if self.reticulum_config_dir:
+ env["RNS_CONFIG"] = self.reticulum_config_dir
+ if self.rngit_config_dir:
+ env["RNGIT_CONFIG"] = self.rngit_config_dir
+ return env
+
+ async def fetch(
+ self,
+ *,
+ destination_hash: str,
+ group: str,
+ repository: str,
+ paths: list[str],
+ ref: str = "HEAD",
+ job_id: str,
+ timeout_seconds: float = 300.0,
+ on_phase: Callable[[str], None] | None = None,
+ ) -> RngitFetchResult:
+ ok, missing = tools_available(which=self._which)
+ if not ok:
+ raise RngitFetchError("rngit_tools_unavailable", missing)
+
+ if not paths:
+ raise RngitFetchError("missing_paths")
+
+ workdir = os.path.join(self.work_root, job_id)
+ if os.path.exists(workdir):
+ shutil.rmtree(workdir, ignore_errors=True)
+ os.makedirs(workdir, exist_ok=True)
+
+ env = self._build_env()
+ remote = f"rns://{destination_hash}/{group}/{repository}"
+ deadline_budget = float(timeout_seconds)
+
+ def emit(phase: str) -> None:
+ if on_phase:
+ try:
+ on_phase(phase)
+ except Exception:
+ pass
+
+ try:
+ self._check_cancelled()
+ emit("cloning")
+ code, _out, err = await _run_git(
+ [
+ "git",
+ "clone",
+ "--filter=blob:none",
+ "--sparse",
+ "--no-checkout",
+ remote,
+ workdir,
+ ],
+ cwd=None,
+ env=env,
+ timeout=deadline_budget,
+ processes=self._processes,
+ )
+ if code != 0:
+ raise RngitFetchError(
+ "git_clone_failed",
+ err.decode("utf-8", errors="replace")[:500],
+ )
+
+ self._check_cancelled()
+ emit("sparse_checkout")
+ code, _out, err = await _run_git(
+ ["git", "sparse-checkout", "set", "--no-cone", "--", *paths],
+ cwd=workdir,
+ env=env,
+ timeout=min(60.0, deadline_budget),
+ processes=self._processes,
+ )
+ if code != 0:
+ raise RngitFetchError(
+ "sparse_checkout_failed",
+ err.decode("utf-8", errors="replace")[:500],
+ )
+
+ self._check_cancelled()
+ emit("fetching_ref")
+ fetch_ref = ref if ref != "HEAD" else "HEAD"
+ if is_commit_like_ref(ref):
+ code, _out, err = await _run_git(
+ ["git", "fetch", "--depth", "1", "origin", ref],
+ cwd=workdir,
+ env=env,
+ timeout=deadline_budget,
+ processes=self._processes,
+ )
+ if code != 0:
+ raise RngitFetchError(
+ "git_fetch_failed",
+ err.decode("utf-8", errors="replace")[:500],
+ )
+ checkout_target = "FETCH_HEAD"
+ else:
+ code, _out, err = await _run_git(
+ ["git", "fetch", "--depth", "1", "origin", fetch_ref],
+ cwd=workdir,
+ env=env,
+ timeout=deadline_budget,
+ processes=self._processes,
+ )
+ if code != 0 and fetch_ref != "HEAD":
+ raise RngitFetchError(
+ "git_fetch_failed",
+ err.decode("utf-8", errors="replace")[:500],
+ )
+ checkout_target = "FETCH_HEAD" if code == 0 else "HEAD"
+
+ self._check_cancelled()
+ emit("checking_out")
+ code, _out, err = await _run_git(
+ ["git", "checkout", checkout_target, "--", *paths],
+ cwd=workdir,
+ env=env,
+ timeout=min(120.0, deadline_budget),
+ processes=self._processes,
+ )
+ if code != 0:
+ # Fallback: checkout tree then ensure paths exist
+ code2, _out2, err2 = await _run_git(
+ ["git", "checkout", checkout_target],
+ cwd=workdir,
+ env=env,
+ timeout=min(120.0, deadline_budget),
+ processes=self._processes,
+ )
+ if code2 != 0:
+ raise RngitFetchError(
+ "git_checkout_failed",
+ (err or err2).decode("utf-8", errors="replace")[:500],
+ )
+
+ code, out, err = await _run_git(
+ ["git", "rev-parse", "HEAD"],
+ cwd=workdir,
+ env=env,
+ timeout=30.0,
+ processes=self._processes,
+ )
+ if code != 0:
+ raise RngitFetchError("rev_parse_failed")
+ resolved = out.decode("utf-8", errors="replace").strip()
+
+ files: dict[str, bytes] = {}
+ root = Path(workdir)
+ for rel in paths:
+ abs_path = (root / rel).resolve()
+ try:
+ abs_path.relative_to(root.resolve())
+ except ValueError as exc:
+ raise RngitFetchError("path_traversal") from exc
+ if not abs_path.is_file():
+ raise RngitFetchError("path_missing", rel)
+ files[rel] = abs_path.read_bytes()
+
+ emit("done")
+ return RngitFetchResult(files=files, resolved_ref=resolved)
+ finally:
+ shutil.rmtree(workdir, ignore_errors=True)
+ self._processes.clear()
diff --git a/meshchatx/src/backend/rnpath_trace_handler.py b/meshchatx/src/backend/rnpath_trace_handler.py
index 10955d0b..7d7405d9 100644
--- a/meshchatx/src/backend/rnpath_trace_handler.py
+++ b/meshchatx/src/backend/rnpath_trace_handler.py
@@ -62,7 +62,17 @@ class RNPathTraceHandler:
path.append({"type": "local", "hash": local_hash, "name": "Local Node"})
- if hops == 1:
+ if hops == 0:
+ path.append(
+ {
+ "type": "destination",
+ "hash": destination_hash_str,
+ "hops": 0,
+ "interface": next_hop_interface,
+ "name": "Local destination",
+ },
+ )
+ elif hops == 1:
# Direct
path.append(
{
diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py
index 09212356..e63536ca 100644
--- a/meshchatx/src/backend/self_check.py
+++ b/meshchatx/src/backend/self_check.py
@@ -706,7 +706,7 @@ def check_plugins_runtime(app: Any) -> dict[str, str]:
plugins_enabled = bool(getattr(app, "plugins_enabled", True))
if not plugins_enabled:
return _status(True)
- bundled_id = "com.meshchatx.mesh-observatory"
+ bundled_id = "com.meshchatx.mcx-bugs"
if any(isinstance(item, dict) and item.get("id") == bundled_id for item in plugins):
return _status(True)
try:
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 97159768..72191005 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -17,8 +17,15 @@
:view-backend-logs-label="$t('app.view_backend_logs')"
:show-ws-reconnected="wsReconnectedBanner"
:ws-reconnected-label="$t('app.backend_reconnected')"
+ :show-network-degraded="showNetworkDegradedBanner"
+ :network-degraded-label="networkDegradedBannerLabel"
+ :network-recovering="networkRecovering"
+ :recover-network-label="$t('app.recover_network')"
+ :open-interfaces-label="$t('app.open_interfaces')"
@restart-backend="onRestartBackend"
@view-backend-logs="onViewBackendCrashReport"
+ @recover-network="onRecoverNetwork"
+ @open-interfaces="onOpenInterfacesForRecovery"
/>
<RouterView v-if="$route.name === 'auth'" />
@@ -425,22 +432,24 @@
</div>
<div class="flex flex-1 min-w-0 overflow-hidden">
- <RouterView v-slot="{ Component, route }" class="flex-1 min-w-0 h-full">
+ <RouterView v-slot="{ Component, route }" class="flex-1 min-w-0 h-full bg-sem-canvas">
<template v-if="Component">
<KeepAlive>
<component
:is="Component"
v-if="route.meta.keepAlive"
:key="route.name"
- class="flex-1 min-w-0 h-full"
+ class="flex-1 min-w-0 h-full bg-sem-canvas"
/>
</KeepAlive>
- <component
- :is="Component"
- v-if="!route.meta.keepAlive"
- :key="route.meta.stableKey ? route.name : route.fullPath"
- class="flex-1 min-w-0 h-full"
- />
+ <Transition name="route-view-fade" mode="out-in">
+ <component
+ :is="Component"
+ v-if="!route.meta.keepAlive"
+ :key="route.meta.stableKey ? route.name : route.fullPath"
+ class="flex-1 min-w-0 h-full bg-sem-canvas"
+ />
+ </Transition>
</template>
</RouterView>
</div>
@@ -676,6 +685,8 @@ export default {
backendProcessExited: false,
backendExitCode: null,
backendRestarting: false,
+ networkRecovering: false,
+ userInitiatedPropagationSync: false,
identitySwitchDedupeHash: null,
identitySwitchDedupeAt: 0,
@@ -705,6 +716,11 @@ export default {
return listNavItems().filter((item) => this.isNavItemVisible(item));
},
isSyncingPropagationNode() {
+ // Only treat sync as "running" in the chrome when the user started it.
+ // Background auto-sync must not keep the header spinner forever.
+ if (!this.userInitiatedPropagationSync) {
+ return false;
+ }
return [
"path_requested",
"link_establishing",
@@ -738,6 +754,16 @@ export default {
typeof window.electron?.restartBackend === "function"
);
},
+ showNetworkDegradedBanner() {
+ return Boolean(GlobalState.networkDegraded) && this.$route?.name !== "auth";
+ },
+ networkDegradedBannerLabel() {
+ const detail = GlobalState.networkDegradedError;
+ if (detail) {
+ return `${this.$t("app.network_degraded")}: ${detail}`;
+ }
+ return this.$t("app.network_degraded");
+ },
identitySidebarLabel() {
const raw = this.displayName;
const name = raw != null && String(raw).trim() !== "" ? String(raw).trim() : "";
@@ -1013,6 +1039,34 @@ export default {
this.backendRestarting = false;
}
},
+ onOpenInterfacesForRecovery() {
+ this.$router.push({ name: "interfaces" });
+ },
+ async onRecoverNetwork() {
+ if (this.networkRecovering) {
+ return;
+ }
+ this.networkRecovering = true;
+ try {
+ const response = await window.api.post("/api/v1/reticulum/recover", {});
+ if (response.data?.status?.network_ready) {
+ GlobalState.networkDegraded = false;
+ GlobalState.networkDegradedError = null;
+ ToastUtils.success(response.data.message || this.$t("app.network_recovered"));
+ return;
+ }
+ const err = response.data?.error || response.data?.message || this.$t("app.network_recover_failed");
+ GlobalState.networkDegradedError = err;
+ ToastUtils.error(err);
+ } catch (e) {
+ const err =
+ e.response?.data?.error || e.response?.data?.message || this.$t("app.network_recover_failed");
+ GlobalState.networkDegradedError = err;
+ ToastUtils.error(err);
+ } finally {
+ this.networkRecovering = false;
+ }
+ },
async onViewBackendCrashReport() {
if (!window.electron?.openBackendCrashReport) {
return;
@@ -1594,6 +1648,8 @@ export default {
return;
}
+ this.userInitiatedPropagationSync = true;
+
// request sync
try {
const preferredHash = this.config?.lxmf_preferred_propagation_node_destination_hash;
@@ -1602,6 +1658,7 @@ export default {
}
await window.api.get("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
+ this.userInitiatedPropagationSync = false;
const errorMessage = e.response?.data?.message ?? this.$t("app.sync_error_generic");
ToastUtils.error(errorMessage);
return;
@@ -1626,6 +1683,7 @@ export default {
this._propagationSyncPollTimer = null;
}
await this.stopSyncingPropagationNode();
+ this.userInitiatedPropagationSync = false;
ToastUtils.error(
this.$t("app.sync_error", {
status: this.propagationSyncStatusLabel("path_timeout"),
@@ -1640,6 +1698,7 @@ export default {
clearInterval(this._propagationSyncPollTimer);
this._propagationSyncPollTimer = null;
}
+ this.userInitiatedPropagationSync = false;
ToastUtils.dismiss(propagationSyncToastKey);
const status = this.propagationNodeStatus?.state;
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
@@ -1665,6 +1724,8 @@ export default {
if (this.isSyncingPropagationNode) {
ToastUtils.loading(this.propagationSyncLiveToastMessage(), 0, propagationSyncToastKey);
this._propagationSyncPollTimer = setInterval(poll, 500);
+ } else {
+ this.userInitiatedPropagationSync = false;
}
await poll();
},
@@ -1697,6 +1758,7 @@ export default {
}
// Clear the polling guard flag
this._isPropagationSyncPolling = false;
+ this.userInitiatedPropagationSync = false;
ToastUtils.dismiss(propagationSyncToastKey);
await this.updatePropagationNodeStatus();
},
@@ -1704,6 +1766,21 @@ export default {
try {
const response = await window.api.get("/api/v1/lxmf/propagation-node/status");
this.propagationNodeStatus = response.data.propagation_node_status;
+ const state = this.propagationNodeStatus?.state;
+ if (
+ this.userInitiatedPropagationSync &&
+ state &&
+ ![
+ "path_requested",
+ "link_establishing",
+ "link_established",
+ "request_sent",
+ "receiving",
+ "response_received",
+ ].includes(state)
+ ) {
+ this.userInitiatedPropagationSync = false;
+ }
} catch {
// do nothing on error
}
diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 4c0d7e2a..5912d460 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -281,7 +281,7 @@
<input
ref="identityImportFileInput"
type="file"
- accept=".identity,.bin,.key"
+ accept=".bin,.key,.identity,application/octet-stream,*/*"
class="hidden"
@change="onIdentityImportFileChange"
/>
@@ -294,7 +294,7 @@
? 'border-blue-500 bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
"
- @click="identityMode = 'new'"
+ @click="setIdentityMode('new')"
>
<v-icon icon="mdi-account-plus-outline" color="blue" size="34"></v-icon>
<div>
@@ -314,7 +314,7 @@
? 'border-blue-500 bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
"
- @click="identityMode = 'import'"
+ @click="setIdentityMode('import')"
>
<v-icon icon="mdi-file-import-outline" color="indigo" size="34"></v-icon>
<div>
@@ -341,9 +341,13 @@
v-if="identityMode === 'import'"
class="space-y-3 pt-2 border-t border-gray-200 dark:border-zinc-800"
>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("tutorial.identity_import_key_only_hint") }}
+ </p>
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary w-full justify-center"
+ :disabled="identityImportInProgress"
@click="$refs.identityImportFileInput?.click()"
>
{{
@@ -357,9 +361,17 @@
rows="3"
class="w-full rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-xs font-mono text-gray-900 dark:text-zinc-100"
:placeholder="$t('tutorial.identity_base32_placeholder')"
+ :disabled="Boolean(identityImportFile) || identityImportInProgress"
+ @input="onIdentityImportBase32Input"
/>
+ <p
+ v-if="identityImportFile && identityImportBase32.trim()"
+ class="text-xs text-amber-600 dark:text-amber-400"
+ >
+ {{ $t("tutorial.identity_file_overrides_base32") }}
+ </p>
</div>
- <p v-if="identityImportError" class="text-sm text-red-600 dark:text-red-400">
+ <p v-if="identityImportError" role="alert" class="text-sm text-red-600 dark:text-red-400">
{{ identityImportError }}
</p>
</div>
@@ -758,7 +770,10 @@
v-if="showFooterContinue"
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
- :disabled="currentStep === 2 && identityImportInProgress"
+ :disabled="
+ (currentStep === 2 && identityImportInProgress) ||
+ (currentStep === 2 && identityMode === 'import' && !hasIdentityImportInput)
+ "
@click="handlePrimaryAction"
>
{{ $t("tutorial.next") }}
@@ -768,6 +783,7 @@
v-else
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
+ :disabled="finishingTutorial"
@click="finishTutorial"
>
{{ $t("tutorial.finish_setup") }}
@@ -1050,7 +1066,7 @@
<input
ref="identityImportFileInput"
type="file"
- accept=".identity,.bin,.key"
+ accept=".bin,.key,.identity,application/octet-stream,*/*"
class="hidden"
@change="onIdentityImportFileChange"
/>
@@ -1063,7 +1079,7 @@
? 'border-blue-500 bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
"
- @click="identityMode = 'new'"
+ @click="setIdentityMode('new')"
>
<v-icon icon="mdi-account-plus-outline" color="blue" size="52"></v-icon>
<div>
@@ -1083,7 +1099,7 @@
? 'border-blue-500 bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
"
- @click="identityMode = 'import'"
+ @click="setIdentityMode('import')"
>
<v-icon icon="mdi-file-import-outline" color="indigo" size="52"></v-icon>
<div>
@@ -1112,9 +1128,13 @@
v-if="identityMode === 'import'"
class="space-y-4 pt-3 border-t border-gray-200 dark:border-zinc-800"
>
+ <p class="text-sm text-gray-500 dark:text-zinc-400">
+ {{ $t("tutorial.identity_import_key_only_hint") }}
+ </p>
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary w-full justify-center"
+ :disabled="identityImportInProgress"
@click="$refs.identityImportFileInput?.click()"
>
{{
@@ -1128,9 +1148,17 @@
rows="4"
class="w-full rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-4 py-3 text-sm font-mono text-gray-900 dark:text-zinc-100"
:placeholder="$t('tutorial.identity_base32_placeholder')"
+ :disabled="Boolean(identityImportFile) || identityImportInProgress"
+ @input="onIdentityImportBase32Input"
/>
+ <p
+ v-if="identityImportFile && identityImportBase32.trim()"
+ class="text-sm text-amber-600 dark:text-amber-400"
+ >
+ {{ $t("tutorial.identity_file_overrides_base32") }}
+ </p>
</div>
- <p v-if="identityImportError" class="text-sm text-red-600 dark:text-red-400">
+ <p v-if="identityImportError" role="alert" class="text-sm text-red-600 dark:text-red-400">
{{ identityImportError }}
</p>
</div>
@@ -1569,7 +1597,10 @@
v-if="showFooterContinue"
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
- :disabled="currentStep === 2 && identityImportInProgress"
+ :disabled="
+ (currentStep === 2 && identityImportInProgress) ||
+ (currentStep === 2 && identityMode === 'import' && !hasIdentityImportInput)
+ "
@click="handlePrimaryAction"
>
{{ $t("tutorial.continue") }}
@@ -1579,6 +1610,7 @@
v-else
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
+ :disabled="finishingTutorial"
@click="finishTutorial"
>
{{ $t("tutorial.finish_setup") }}
@@ -1620,6 +1652,7 @@ export default {
identityImportError: "",
identityImportedHash: null,
originalIdentityHash: null,
+ finishingTutorial: false,
interfaceAddedViaTutorial: false,
connectionMode: null,
addingLocal: false,
@@ -1652,7 +1685,7 @@ export default {
return "Anonymous Peer";
},
hasIdentityImportInput() {
- return Boolean(this.identityImportFile || this.identityImportBase32.trim());
+ return Boolean(this.identityImportFile || this.normalizeBase32(this.identityImportBase32));
},
showFooterContinue() {
if (this.currentStep === 3) {
@@ -1687,6 +1720,23 @@ export default {
this.identityImportInProgress = false;
this.identityImportedHash = null;
this.originalIdentityHash = null;
+ this.finishingTutorial = false;
+ },
+ setIdentityMode(mode) {
+ this.identityMode = mode;
+ this.identityImportError = "";
+ if (mode === "new") {
+ this.identityImportFile = null;
+ this.identityImportBase32 = "";
+ this.identityImportedHash = null;
+ }
+ },
+ normalizeBase32(value) {
+ return String(value || "").replace(/\s+/g, "");
+ },
+ onIdentityImportBase32Input() {
+ this.identityImportedHash = null;
+ this.identityImportError = "";
},
async loadIdentitySetupDefaults() {
try {
@@ -1705,8 +1755,18 @@ export default {
},
onIdentityImportFileChange(event) {
const files = event?.target?.files;
- this.identityImportFile = files?.[0] || null;
+ const file = files?.[0] || null;
+ this.identityImportedHash = null;
this.identityImportError = "";
+ if (file && file.size === 0) {
+ this.identityImportFile = null;
+ this.identityImportError = this.$t("tutorial.identity_import_empty_file");
+ } else if (file && file.size > 65536) {
+ this.identityImportFile = null;
+ this.identityImportError = this.$t("tutorial.identity_import_file_too_large");
+ } else {
+ this.identityImportFile = file;
+ }
if (event?.target) {
event.target.value = "";
}
@@ -1723,7 +1783,7 @@ export default {
return response.data?.identity?.hash || null;
},
async importIdentityFromBase32(base32, displayName) {
- const payload = { base32 };
+ const payload = { base32: this.normalizeBase32(base32) };
if (displayName) {
payload.display_name = displayName;
}
@@ -1761,7 +1821,7 @@ export default {
importedHash = await this.importIdentityFromFile(this.identityImportFile, trimmedName);
this.identityImportFile = null;
} else {
- importedHash = await this.importIdentityFromBase32(this.identityImportBase32.trim(), trimmedName);
+ importedHash = await this.importIdentityFromBase32(this.identityImportBase32, trimmedName);
this.identityImportBase32 = "";
}
if (!importedHash) {
@@ -1956,20 +2016,41 @@ export default {
}
},
gotoAddInterface() {
- if (!this.isPage) {
- this.visible = false;
- }
- if (this.$router) {
- this.$router.push({ path: "/interfaces/add" });
- }
+ void this.closeWithPendingImportGuard().then((closed) => {
+ if (!closed) {
+ return;
+ }
+ if (this.$router) {
+ this.$router.push({ path: "/interfaces/add" });
+ }
+ });
},
gotoRoute(routeName) {
+ void this.closeWithPendingImportGuard().then((closed) => {
+ if (!closed) {
+ return;
+ }
+ if (this.$router) {
+ this.$router.push({ name: routeName });
+ }
+ });
+ },
+ async closeWithPendingImportGuard() {
+ if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
+ const activate = await DialogUtils.confirm(this.$t("tutorial.identity_import_pending_activate"));
+ if (activate) {
+ const activated = await this.activateImportedIdentity();
+ if (!activated) {
+ return false;
+ }
+ } else {
+ ToastUtils.warning(this.$t("tutorial.identity_import_pending_kept"));
+ }
+ }
if (!this.isPage) {
this.visible = false;
}
- if (this.$router) {
- this.$router.push({ name: routeName });
- }
+ return true;
},
async handlePrimaryAction() {
if (this.currentStep === 2) {
@@ -1997,10 +2078,22 @@ export default {
this.currentStep--;
},
async skipTutorial() {
- if (await DialogUtils.confirm(this.$t("tutorial.skip_confirm"))) {
- this.visible = false;
- this.markSeen();
+ if (!(await DialogUtils.confirm(this.$t("tutorial.skip_confirm")))) {
+ return;
+ }
+ if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
+ const activate = await DialogUtils.confirm(this.$t("tutorial.identity_import_pending_activate"));
+ if (activate) {
+ const activated = await this.activateImportedIdentity();
+ if (!activated) {
+ return;
+ }
+ } else {
+ ToastUtils.warning(this.$t("tutorial.identity_import_pending_kept"));
+ }
}
+ this.visible = false;
+ this.markSeen();
},
async markSeen() {
if (this.markingSeen) return;
@@ -2013,35 +2106,71 @@ export default {
this.markingSeen = false;
}
},
- async finishTutorial() {
- if (GlobalState.hasPendingInterfaceChanges) {
- const reloaded = await this.reloadReticulum();
- if (!reloaded) {
- return;
- }
+ async activateImportedIdentity() {
+ if (!this.identityImportedHash) {
+ return true;
}
- if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
- try {
- await window.api.post("/api/v1/identities/switch", {
- identity_hash: this.identityImportedHash,
- });
- if (this.originalIdentityHash) {
+ if (this.identityImportedHash === this.originalIdentityHash) {
+ return true;
+ }
+ try {
+ const response = await window.api.post("/api/v1/identities/switch", {
+ identity_hash: this.identityImportedHash,
+ });
+ if (this.originalIdentityHash) {
+ try {
await window.api.delete(`/api/v1/identities/${this.originalIdentityHash}`);
+ } catch (deleteError) {
+ console.error("Failed to delete default identity after import:", deleteError);
+ ToastUtils.warning(this.$t("tutorial.identity_default_delete_failed"));
}
- } catch (e) {
- ToastUtils.error(e.response?.data?.message || this.$t("tutorial.identity_switch_failed"));
- return;
}
+ if (response?.data?.hotswapped === false) {
+ ToastUtils.info(this.$t("identities.switch_scheduled"));
+ setTimeout(() => {
+ window.location.reload();
+ }, 1500);
+ }
+ this.identityImportedHash = null;
+ return true;
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("tutorial.identity_switch_failed"));
+ return false;
}
- await this.markSeen();
- this.visible = false;
- if (this.interfaceAddedViaTutorial) {
- ToastUtils.success(this.$t("tutorial.ready_finished"));
+ },
+ async finishTutorial() {
+ if (this.finishingTutorial) {
+ return;
+ }
+ this.finishingTutorial = true;
+ try {
+ if (GlobalState.hasPendingInterfaceChanges) {
+ const reloaded = await this.reloadReticulum();
+ if (!reloaded) {
+ return;
+ }
+ }
+ if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
+ const activated = await this.activateImportedIdentity();
+ if (!activated) {
+ return;
+ }
+ }
+ await this.markSeen();
+ this.visible = false;
+ if (this.interfaceAddedViaTutorial) {
+ ToastUtils.success(this.$t("tutorial.ready_finished"));
+ }
+ } finally {
+ this.finishingTutorial = false;
}
},
async onVisibleUpdate(val) {
if (!val) {
- // if closed by clicking away or programmatically, mark as seen
+ // Closing without finish still marks seen, but warn if import was pending.
+ if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
+ ToastUtils.warning(this.$t("tutorial.identity_import_pending_kept"));
+ }
this.markSeen();
}
},
diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index c76d48e8..4bd067e8 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -554,7 +554,7 @@
</div>
<div>
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
- {{ $t("about.dep_lxmfy_subtitle") }}
+ LXMFy
</div>
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
v{{ (appInfo.dependencies && appInfo.dependencies.lxmfy) || "unknown" }}
@@ -574,7 +574,7 @@
</div>
<div>
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
- {{ $t("about.dep_lxmf_subtitle") }}
+ LXMF
</div>
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
v{{ appInfo.lxmf_version }}
@@ -594,7 +594,7 @@
</div>
<div>
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
- {{ $t("about.dep_rns_subtitle") }}
+ RNS
</div>
<div class="flex flex-wrap items-center gap-2 mt-1 min-w-0">
<div class="text-xs font-mono font-bold text-gray-400 shrink-0">
diff --git a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
index e789c6d9..450637a9 100644
--- a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
+++ b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
@@ -149,6 +149,7 @@
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
+import ToastUtils from "../../js/ToastUtils";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
export default {
@@ -195,33 +196,51 @@ export default {
},
addRule() {
if (!this.newRule.forward_to_hash) return;
- WebSocketConnection.send(
+ const hash = String(this.newRule.forward_to_hash || "").trim();
+ if (hash.length !== 32 || !/^[0-9a-fA-F]+$/.test(hash)) {
+ ToastUtils.warning(this.$t("forwarder.invalid_hash"));
+ return;
+ }
+ const sent = WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.add",
- rule: { ...this.newRule },
+ rule: { ...this.newRule, forward_to_hash: hash },
})
);
+ if (sent === false) {
+ ToastUtils.error(this.$t("forwarder.send_failed"));
+ return;
+ }
this.newRule.name = "";
this.newRule.forward_to_hash = "";
this.newRule.source_filter_hash = "";
+ ToastUtils.success(this.$t("forwarder.rule_added"));
},
async deleteRule(id) {
if (await DialogUtils.confirm(this.$t("forwarder.delete_confirm"))) {
- WebSocketConnection.send(
+ const sent = WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.delete",
id: id,
})
);
+ if (sent === false) {
+ ToastUtils.error(this.$t("forwarder.send_failed"));
+ return;
+ }
+ ToastUtils.success(this.$t("forwarder.rule_deleted"));
}
},
toggleRule(id) {
- WebSocketConnection.send(
+ const sent = WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.toggle",
id: id,
})
);
+ if (sent === false) {
+ ToastUtils.error(this.$t("forwarder.send_failed"));
+ }
},
},
};
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 0eb14c39..3c6c6ae1 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -2122,9 +2122,7 @@ export default {
return this.reticulumInstance.enable_transport === true;
},
hasExistingI2PInterface() {
- return Object.values(this.existingInterfaces || {}).some(
- (iface) => iface && iface.type === "I2PInterface"
- );
+ return Object.values(this.existingInterfaces || {}).some((iface) => iface && iface.type === "I2PInterface");
},
canAddI2PInterface() {
if (this.isEditingInterface && this.newInterfaceType === "I2PInterface") {
diff --git a/meshchatx/src/frontend/components/layout/AppShellBanners.vue b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
index f4f15d7a..89651039 100644
--- a/meshchatx/src/frontend/components/layout/AppShellBanners.vue
+++ b/meshchatx/src/frontend/components/layout/AppShellBanners.vue
@@ -45,6 +45,31 @@
>
{{ wsReconnectedLabel }}
</div>
+ <div
+ v-if="showNetworkDegraded"
+ class="relative z-100 bg-amber-700 text-white px-4 py-3 text-center text-sm font-medium shadow-md border-b border-amber-800/80"
+ role="status"
+ aria-live="polite"
+ >
+ <p>{{ networkDegradedLabel }}</p>
+ <div class="mt-2 flex flex-wrap items-center justify-center gap-2">
+ <button
+ type="button"
+ class="rounded-md bg-white/15 px-3 py-1 text-xs font-semibold hover:bg-white/25 disabled:opacity-60"
+ :disabled="networkRecovering"
+ @click="$emit('recover-network')"
+ >
+ {{ recoverNetworkLabel }}
+ </button>
+ <button
+ type="button"
+ class="rounded-md bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
+ @click="$emit('open-interfaces')"
+ >
+ {{ openInterfacesLabel }}
+ </button>
+ </div>
+ </div>
</div>
</template>
@@ -95,7 +120,27 @@ export default {
type: String,
default: "",
},
+ showNetworkDegraded: {
+ type: Boolean,
+ default: false,
+ },
+ networkDegradedLabel: {
+ type: String,
+ default: "",
+ },
+ networkRecovering: {
+ type: Boolean,
+ default: false,
+ },
+ recoverNetworkLabel: {
+ type: String,
+ default: "",
+ },
+ openInterfacesLabel: {
+ type: String,
+ default: "",
+ },
},
- emits: ["restart-backend", "view-backend-logs"],
+ emits: ["restart-backend", "view-backend-logs", "recover-network", "open-interfaces"],
};
</script>
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index e5f839fd..9392e89c 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -571,6 +571,14 @@
@export-kmz="exportVectorKmz"
/>
+ <MapRemoteOverlayPanel
+ :disabled="!map"
+ @overlays-changed="onRemoteOverlaysChanged"
+ @export-overlay="onRemoteOverlayExport"
+ @copy-overlay-to-drawings="onRemoteOverlayCopyToDrawings"
+ @error="onRemoteOverlayError"
+ />
+
<!-- Map Style Presets -->
<div v-if="!offlineEnabled" class="space-y-2">
<div class="flex items-center justify-between">
@@ -1166,6 +1174,7 @@ import MapExportConfigPanel from "./internal/MapExportConfigPanel.vue";
import MapExportProgressPanel from "./internal/MapExportProgressPanel.vue";
import MapLoadingOverlay from "./internal/MapLoadingOverlay.vue";
import MapVectorExchangePanel from "./internal/MapVectorExchangePanel.vue";
+import MapRemoteOverlayPanel from "./internal/MapRemoteOverlayPanel.vue";
import { buildMeshchatMapUri, buildWebHashMapUrl } from "../../js/mapLinkUtils.js";
import { readGeoJsonToFeatures, writeFeaturesToGeoJson } from "../../js/mapExchange/geoJsonCodec.js";
import { readKmlToFeatures, writeFeaturesToKml } from "../../js/mapExchange/kmlCodec.js";
@@ -1203,6 +1212,7 @@ export default {
MapExportProgressPanel,
MapLoadingOverlay,
MapVectorExchangePanel,
+ MapRemoteOverlayPanel,
},
props: {
embedded: {
@@ -1300,6 +1310,10 @@ export default {
showTileConnectivityBanner: false,
tileConnectivityBannerTimer: null,
+ // remote overlay layers (id -> { source, layer })
+ remoteOverlayLayers: {},
+ remoteOverlayLoadGeneration: 0,
+
// drawing tools
draw: null,
modify: null,
@@ -1636,6 +1650,9 @@ export default {
settingsEl.style.willChange = "";
}
if (this.map) {
+ for (const id of Object.keys(this.remoteOverlayLayers || {})) {
+ this.removeRemoteOverlayLayer(id);
+ }
const v = this.map.getView();
if (v && typeof v.un === "function") {
v.un("change:rotation", this.syncMapNorthIndicatorFromViewRotation);
@@ -4449,6 +4466,162 @@ export default {
ToastUtils.error(this.$t("map.vector_import_failed"));
},
+ onRemoteOverlayError(err) {
+ console.error(err);
+ ToastUtils.error(this.$t("map.remote_overlays_error"));
+ },
+
+ async onRemoteOverlaysChanged(overlays) {
+ if (!this.map) {
+ return;
+ }
+ const gen = ++this.remoteOverlayLoadGeneration;
+ const list = Array.isArray(overlays) ? overlays : [];
+ const keep = new Set(list.map((o) => String(o.id)));
+ for (const id of Object.keys(this.remoteOverlayLayers)) {
+ if (!keep.has(id)) {
+ this.removeRemoteOverlayLayer(id);
+ }
+ }
+ for (const overlay of list) {
+ if (gen !== this.remoteOverlayLoadGeneration) {
+ return;
+ }
+ const id = String(overlay.id);
+ const visible = Boolean(overlay.visible);
+ if (overlay.status !== "ready" || !overlay.format) {
+ const existing = this.remoteOverlayLayers[id];
+ if (existing?.layer) {
+ existing.layer.setVisible(false);
+ }
+ continue;
+ }
+ try {
+ await this.ensureRemoteOverlayLayer(overlay);
+ if (gen !== this.remoteOverlayLoadGeneration) {
+ return;
+ }
+ const entry = this.remoteOverlayLayers[id];
+ if (entry?.layer) {
+ entry.layer.setVisible(visible);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+
+ removeRemoteOverlayLayer(id) {
+ const entry = this.remoteOverlayLayers[id];
+ if (!entry) {
+ return;
+ }
+ if (this.map && entry.layer) {
+ this.map.removeLayer(entry.layer);
+ }
+ delete this.remoteOverlayLayers[id];
+ },
+
+ async ensureRemoteOverlayLayer(overlay) {
+ const id = String(overlay.id);
+ const contentRes = await fetch(`/api/v1/map/overlays/${overlay.id}/content`, {
+ credentials: "same-origin",
+ });
+ if (!contentRes.ok) {
+ throw new Error(`overlay content ${contentRes.status}`);
+ }
+ let features = [];
+ const fmt = overlay.format;
+ if (fmt === "kmz") {
+ const buf = await contentRes.arrayBuffer();
+ features = await readKmzToFeatures(buf, "EPSG:3857");
+ } else {
+ const text = await contentRes.text();
+ if (fmt === "kml") {
+ features = readKmlToFeatures(text, "EPSG:3857");
+ } else {
+ features = readGeoJsonToFeatures(text, "EPSG:3857");
+ }
+ }
+ for (const f of features) {
+ f.set("type", "remote_overlay");
+ f.set("overlay_id", overlay.id);
+ }
+ let entry = this.remoteOverlayLayers[id];
+ if (!entry) {
+ const source = new VectorSource();
+ const layer = new VectorLayer({
+ source,
+ zIndex: 45,
+ opacity: 0.95,
+ });
+ this.map.addLayer(layer);
+ entry = { source, layer, sha: overlay.content_sha256 };
+ this.remoteOverlayLayers[id] = entry;
+ }
+ entry.source.clear();
+ entry.source.addFeatures(features);
+ entry.sha = overlay.content_sha256;
+ },
+
+ async onRemoteOverlayExport({ id, format }) {
+ try {
+ const res = await fetch(`/api/v1/map/overlays/${id}/export?format=${encodeURIComponent(format)}`, {
+ credentials: "same-origin",
+ });
+ if (!res.ok) {
+ throw new Error(`export ${res.status}`);
+ }
+ const blob = await res.blob();
+ const cd = res.headers.get("Content-Disposition") || "";
+ const match = /filename="([^"]+)"/.exec(cd);
+ const name = match?.[1] || `overlay-${id}.${format}`;
+ this.downloadBlobFile(name, blob, blob.type || "application/octet-stream");
+ ToastUtils.success(this.$t("map.remote_overlays_export_ok"));
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("map.remote_overlays_export_failed"));
+ }
+ },
+
+ async onRemoteOverlayCopyToDrawings(overlay) {
+ if (!this.drawSource || !overlay?.id) {
+ return;
+ }
+ try {
+ const contentRes = await fetch(`/api/v1/map/overlays/${overlay.id}/content`, {
+ credentials: "same-origin",
+ });
+ if (!contentRes.ok) {
+ throw new Error(`overlay content ${contentRes.status}`);
+ }
+ let features = [];
+ const fmt = overlay.format;
+ if (fmt === "kmz") {
+ const buf = await contentRes.arrayBuffer();
+ features = await readKmzToFeatures(buf, "EPSG:3857");
+ } else {
+ const text = await contentRes.text();
+ if (fmt === "kml") {
+ features = readKmlToFeatures(text, "EPSG:3857");
+ } else {
+ features = readGeoJsonToFeatures(text, "EPSG:3857");
+ }
+ }
+ for (const f of features) {
+ f.set("type", "draw");
+ f.unset("overlay_id");
+ }
+ this.drawSource.addFeatures(features);
+ this.rebuildMeasurementOverlays();
+ this.saveMapState();
+ ToastUtils.success(this.$t("map.remote_overlays_copied"));
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("map.remote_overlays_error"));
+ }
+ },
+
onMapDragOver(ev) {
if (ev.dataTransfer && ev.dataTransfer.types.includes("Files")) {
this.isMapDropTarget = true;
diff --git a/meshchatx/src/frontend/components/map/internal/MapRemoteOverlayPanel.vue b/meshchatx/src/frontend/components/map/internal/MapRemoteOverlayPanel.vue
new file mode 100644
index 00000000..5eb6efae
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/internal/MapRemoteOverlayPanel.vue
@@ -0,0 +1,295 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="space-y-3 rounded-xl border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 p-3">
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-widest">{{
+ $t("map.remote_overlays_title")
+ }}</span>
+ <button
+ type="button"
+ class="text-[10px] font-bold uppercase text-blue-600 dark:text-blue-400 disabled:opacity-40"
+ :disabled="loading || disabled"
+ @click="reload"
+ >
+ {{ $t("map.remote_overlays_reload") }}
+ </button>
+ </div>
+
+ <div class="grid grid-cols-1 gap-2">
+ <label class="text-[10px] text-gray-600 dark:text-zinc-400 space-y-1">
+ <span>{{ $t("map.remote_overlays_kind") }}</span>
+ <select
+ v-model="kind"
+ class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-[11px]"
+ >
+ <option value="nomadnet_file">NomadNet /file/</option>
+ <option value="rngit_files">RNGit sparse</option>
+ </select>
+ </label>
+ <label class="text-[10px] text-gray-600 dark:text-zinc-400 space-y-1">
+ <span>{{ $t("map.remote_overlays_url") }}</span>
+ <input
+ v-model="url"
+ type="text"
+ class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-[11px]"
+ :placeholder="kind === 'rngit_files' ? 'rns://hash/group/repo' : 'hash:/file/maps/layer.geojson'"
+ />
+ </label>
+ <label v-if="kind === 'rngit_files'" class="text-[10px] text-gray-600 dark:text-zinc-400 space-y-1">
+ <span>{{ $t("map.remote_overlays_paths") }}</span>
+ <textarea
+ v-model="pathsText"
+ rows="3"
+ class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-[11px] font-mono"
+ placeholder="maps/layer.geojson"
+ />
+ </label>
+ <label v-if="kind === 'rngit_files'" class="text-[10px] text-gray-600 dark:text-zinc-400 space-y-1">
+ <span>{{ $t("map.remote_overlays_ref") }}</span>
+ <input
+ v-model="refName"
+ type="text"
+ class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-[11px]"
+ placeholder="HEAD / tag / commit"
+ />
+ </label>
+ <label class="text-[10px] text-gray-600 dark:text-zinc-400 space-y-1">
+ <span>{{ $t("map.remote_overlays_refresh_interval") }}</span>
+ <input
+ v-model.number="refreshInterval"
+ type="number"
+ min="0"
+ class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-[11px]"
+ />
+ </label>
+ <button
+ type="button"
+ class="py-2 px-2 text-[10px] font-bold uppercase rounded-lg bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-40"
+ :disabled="disabled || importing || !url.trim()"
+ @click="importSources"
+ >
+ {{ importing ? $t("map.remote_overlays_importing") : $t("map.remote_overlays_import") }}
+ </button>
+ <p v-if="jobPhase" class="text-[9px] text-gray-500 dark:text-zinc-500">{{ jobPhase }}</p>
+ </div>
+
+ <div v-if="overlays.length" class="space-y-2 border-t border-gray-200 dark:border-zinc-800 pt-2">
+ <div
+ v-for="overlay in overlays"
+ :key="overlay.id"
+ class="rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950/60 p-2 space-y-1.5"
+ >
+ <div class="flex items-start justify-between gap-2">
+ <div class="min-w-0">
+ <div class="text-[11px] font-semibold text-gray-900 dark:text-zinc-100 truncate">
+ {{ overlay.name }}
+ </div>
+ <div class="text-[9px] text-gray-500 dark:text-zinc-500 truncate">
+ {{ overlay.kind }} · {{ overlay.status }}
+ <span v-if="overlay.format"> · {{ overlay.format }}</span>
+ </div>
+ <div v-if="overlay.last_error" class="text-[9px] text-red-500 truncate">
+ {{ overlay.last_error }}
+ </div>
+ </div>
+ <label class="flex items-center gap-1 text-[9px] text-gray-500 shrink-0">
+ <input
+ type="checkbox"
+ :checked="Boolean(overlay.visible)"
+ @change="toggleVisible(overlay, $event.target.checked)"
+ />
+ {{ $t("map.remote_overlays_visible") }}
+ </label>
+ </div>
+ <div class="flex flex-wrap gap-1">
+ <button
+ type="button"
+ class="px-1.5 py-1 text-[9px] font-bold uppercase rounded bg-gray-100 dark:bg-zinc-800"
+ :disabled="disabled"
+ @click="refresh(overlay)"
+ >
+ {{ $t("map.remote_overlays_refresh") }}
+ </button>
+ <button
+ type="button"
+ class="px-1.5 py-1 text-[9px] font-bold uppercase rounded bg-gray-100 dark:bg-zinc-800"
+ :disabled="disabled || overlay.status !== 'ready'"
+ @click="$emit('export-overlay', { id: overlay.id, format: 'geojson' })"
+ >
+ GeoJSON
+ </button>
+ <button
+ type="button"
+ class="px-1.5 py-1 text-[9px] font-bold uppercase rounded bg-gray-100 dark:bg-zinc-800"
+ :disabled="disabled || overlay.status !== 'ready'"
+ @click="$emit('export-overlay', { id: overlay.id, format: 'kml' })"
+ >
+ KML
+ </button>
+ <button
+ type="button"
+ class="px-1.5 py-1 text-[9px] font-bold uppercase rounded bg-gray-100 dark:bg-zinc-800"
+ :disabled="disabled || overlay.status !== 'ready'"
+ @click="$emit('export-overlay', { id: overlay.id, format: 'kmz' })"
+ >
+ KMZ
+ </button>
+ <button
+ type="button"
+ class="px-1.5 py-1 text-[9px] font-bold uppercase rounded bg-gray-100 dark:bg-zinc-800"
+ :disabled="disabled || overlay.status !== 'ready'"
+ @click="$emit('copy-overlay-to-drawings', overlay)"
+ >
+ {{ $t("map.remote_overlays_copy_drawings") }}
+ </button>
+ <button
+ type="button"
+ class="px-1.5 py-1 text-[9px] font-bold uppercase rounded bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-400"
+ :disabled="disabled"
+ @click="remove(overlay)"
+ >
+ {{ $t("map.remote_overlays_delete") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ <p v-else class="text-[9px] text-gray-500 dark:text-zinc-500">{{ $t("map.remote_overlays_empty") }}</p>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "MapRemoteOverlayPanel",
+ props: {
+ disabled: { type: Boolean, default: false },
+ },
+ emits: ["overlays-changed", "export-overlay", "copy-overlay-to-drawings", "error"],
+ data() {
+ return {
+ kind: "nomadnet_file",
+ url: "",
+ pathsText: "",
+ refName: "HEAD",
+ refreshInterval: 0,
+ overlays: [],
+ loading: false,
+ importing: false,
+ jobPhase: "",
+ pollTimer: null,
+ activeJobId: null,
+ jobGeneration: 0,
+ };
+ },
+ mounted() {
+ this.reload();
+ },
+ beforeUnmount() {
+ this.clearPoll();
+ },
+ methods: {
+ clearPoll() {
+ if (this.pollTimer) {
+ clearInterval(this.pollTimer);
+ this.pollTimer = null;
+ }
+ },
+ async reload() {
+ this.loading = true;
+ try {
+ const res = await window.api.get("/api/v1/map/overlays");
+ this.overlays = res?.overlays || [];
+ this.$emit("overlays-changed", this.overlays);
+ } catch (e) {
+ this.$emit("error", e);
+ } finally {
+ this.loading = false;
+ }
+ },
+ async importSources() {
+ const body = {
+ kind: this.kind,
+ url: this.url.trim(),
+ refresh_interval_seconds: Number(this.refreshInterval) || 0,
+ };
+ if (this.kind === "rngit_files") {
+ body.ref = this.refName || "HEAD";
+ body.paths = this.pathsText
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean);
+ }
+ this.importing = true;
+ this.jobPhase = "queued";
+ try {
+ const res = await window.api.post("/api/v1/map/overlays", body);
+ this.overlays = res?.overlays || this.overlays;
+ this.$emit("overlays-changed", this.overlays);
+ if (res?.job_id) {
+ this.watchJob(res.job_id);
+ }
+ } catch (e) {
+ this.$emit("error", e);
+ this.importing = false;
+ this.jobPhase = "";
+ }
+ },
+ watchJob(jobId) {
+ this.clearPoll();
+ this.activeJobId = jobId;
+ const gen = ++this.jobGeneration;
+ this.pollTimer = setInterval(async () => {
+ if (gen !== this.jobGeneration) {
+ return;
+ }
+ try {
+ const job = await window.api.get(`/api/v1/map/overlays/jobs/${jobId}`);
+ if (gen !== this.jobGeneration) {
+ return;
+ }
+ this.jobPhase = job?.phase || job?.status || "";
+ if (job?.status === "success" || job?.status === "error" || job?.status === "cancelled") {
+ this.clearPoll();
+ this.importing = false;
+ await this.reload();
+ if (job.status !== "success") {
+ this.$emit("error", job.error || job.status);
+ }
+ }
+ } catch (e) {
+ this.clearPoll();
+ this.importing = false;
+ this.$emit("error", e);
+ }
+ }, 1000);
+ },
+ async refresh(overlay) {
+ try {
+ const res = await window.api.post(`/api/v1/map/overlays/${overlay.id}/refresh`, {});
+ if (res?.job_id) {
+ this.importing = true;
+ this.watchJob(res.job_id);
+ }
+ } catch (e) {
+ this.$emit("error", e);
+ }
+ },
+ async toggleVisible(overlay, visible) {
+ try {
+ await window.api.patch(`/api/v1/map/overlays/${overlay.id}`, { visible: Boolean(visible) });
+ await this.reload();
+ } catch (e) {
+ this.$emit("error", e);
+ }
+ },
+ async remove(overlay) {
+ try {
+ await window.api.delete(`/api/v1/map/overlays/${overlay.id}`);
+ await this.reload();
+ } catch (e) {
+ this.$emit("error", e);
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
index fc16302c..51b56261 100644
--- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
@@ -20,6 +20,34 @@
<MaterialDesignIcon icon-name="notebook-outline" class="size-5" />
<span>{{ $t("messages.share_contact") }}</span>
</DropDownMenuItem>
+ <DropDownMenuItem v-if="isMeshChatXAndroid" @click="onShareApk">
+ <MaterialDesignIcon icon-name="share-variant" class="size-5" />
+ <span>{{ $t("messages.share_apk") }}</span>
+ </DropDownMenuItem>
+ <DropDownMenuItem
+ data-testid="path-finder-quick"
+ :class="{ 'opacity-40 pointer-events-none': pathfinderInProgress }"
+ @click="$emit('path-finder-quick')"
+ >
+ <MaterialDesignIcon icon-name="flash" class="size-5" />
+ <span>{{ $t("nomadnet.path_finder_quick_request") }}</span>
+ </DropDownMenuItem>
+ <DropDownMenuItem
+ data-testid="path-finder-force"
+ :class="{ 'opacity-40 pointer-events-none': pathfinderInProgress }"
+ @click="$emit('path-finder-force')"
+ >
+ <MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
+ <span>{{ $t("nomadnet.path_finder_force_find") }}</span>
+ </DropDownMenuItem>
+ <DropDownMenuItem
+ data-testid="path-finder-drop"
+ :class="{ 'opacity-40 pointer-events-none': pathfinderInProgress }"
+ @click="$emit('path-finder-drop')"
+ >
+ <MaterialDesignIcon icon-name="reload-alert" class="size-5" />
+ <span>{{ $t("nomadnet.path_finder_drop_and_request") }}</span>
+ </DropDownMenuItem>
<DropDownMenuItem @click="onPingDestination">
<MaterialDesignIcon icon-name="flash" class="size-5" />
<span>Ping Destination</span>
@@ -46,18 +74,11 @@
<div class="border-t border-gray-100 dark:border-zinc-800" />
- <!-- set custom display name button -->
<DropDownMenuItem @click="onSetCustomDisplayName">
<MaterialDesignIcon icon-name="account-edit" class="size-5" />
<span>Set Custom Display Name</span>
</DropDownMenuItem>
- <!-- popout button -->
- <DropDownMenuItem @click="$emit('popout')">
- <MaterialDesignIcon icon-name="open-in-new" class="size-5" />
- <span>{{ $t("messages.pop_out_chat") }}</span>
- </DropDownMenuItem>
-
<!-- block/unblock button -->
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
@@ -94,6 +115,9 @@
<IconButton :title="$t('messages.share_contact')" class="shrink-0" @click="$emit('share-contact')">
<MaterialDesignIcon icon-name="notebook-outline" class="size-5" />
</IconButton>
+ <IconButton v-if="isMeshChatXAndroid" :title="$t('messages.share_apk')" class="shrink-0" @click="onShareApk">
+ <MaterialDesignIcon icon-name="share-variant" class="size-5" />
+ </IconButton>
<IconButton title="Ping Destination" class="shrink-0" @click="onPingDestination">
<MaterialDesignIcon icon-name="flash" class="size-5" />
</IconButton>
@@ -115,7 +139,12 @@
<IconButton :title="$t('messages.custom_display_name')" class="shrink-0" @click="onSetCustomDisplayName">
<MaterialDesignIcon icon-name="account-edit" class="size-5" />
</IconButton>
- <IconButton :title="$t('messages.pop_out_chat')" class="shrink-0" @click="$emit('popout')">
+ <IconButton
+ data-testid="conversation-popout"
+ :title="$t('messages.pop_out_chat')"
+ class="shrink-0"
+ @click="$emit('popout')"
+ >
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
</IconButton>
<IconButton v-if="!isBlocked" title="Banish User" class="shrink-0" @click="onBlockDestination">
@@ -139,6 +168,7 @@ import DialogUtils from "../../js/DialogUtils";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
+import AndroidBridge from "../../js/rnode/AndroidBridge.js";
export default {
name: "ConversationDropDownMenu",
@@ -161,6 +191,10 @@ export default {
type: Boolean,
default: true,
},
+ pathfinderInProgress: {
+ type: Boolean,
+ default: false,
+ },
},
emits: [
"conversation-deleted",
@@ -172,6 +206,9 @@ export default {
"open-telemetry-history",
"start-call",
"share-contact",
+ "path-finder-quick",
+ "path-finder-force",
+ "path-finder-drop",
],
data() {
return {
@@ -187,6 +224,15 @@ export default {
}
return GlobalState.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
},
+ isMeshChatXAndroid() {
+ return (
+ typeof window !== "undefined" &&
+ window.MeshChatXAndroid &&
+ typeof window.MeshChatXAndroid.getPlatform === "function" &&
+ window.MeshChatXAndroid.getPlatform() === "android" &&
+ typeof window.MeshChatXAndroid.shareApk === "function"
+ );
+ },
},
watch: {
peer: {
@@ -203,6 +249,12 @@ export default {
GlobalEmitter.off("contact-updated", this.onContactUpdated);
},
methods: {
+ onShareApk() {
+ const bridge = new AndroidBridge();
+ if (!bridge.shareApk()) {
+ ToastUtils.error(this.$t("messages.share_apk_failed"));
+ }
+ },
onContactUpdated(data) {
if (this.peer?.destination_hash === data.remote_identity_hash) {
this.fetchContact();
diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
index 99c63796..9c70b560 100644
--- a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
@@ -117,7 +117,7 @@
</div>
<div class="ml-auto flex items-center gap-0.5 sm:gap-1.5 min-w-0 shrink-0">
- <DropDownMenu class="shrink-0">
+ <DropDownMenu v-if="!compactPeerActions" class="shrink-0" data-testid="conversation-path-ops">
<template #button>
<IconButton
:title="$t('nomadnet.path_finder')"
@@ -151,6 +151,7 @@
:peer="selectedPeer"
:compact="compactPeerActions"
:has-failed-messages="hasFailedOrCancelledMessages"
+ :pathfinder-in-progress="pathfinderInProgress"
@conversation-deleted="$emit('conversation-deleted')"
@set-custom-display-name="$emit('edit-display-name')"
@popout="$emit('popout')"
@@ -158,6 +159,9 @@
@open-telemetry-history="$emit('open-telemetry-history')"
@start-call="$emit('start-call')"
@share-contact="$emit('share-contact')"
+ @path-finder-quick="$emit('path-finder-quick')"
+ @path-finder-force="$emit('path-finder-force')"
+ @path-finder-drop="$emit('path-finder-drop')"
/>
<IconButton title="Close" class="shrink-0" @click="$emit('close')">
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index ace54d44..95331dbb 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1972,6 +1972,8 @@ export default {
reactionPickerChatItem: null,
reactionPickerPos: null,
reactionDragState: null,
+ reactionDragCleanup: null,
+ reactionSendInFlight: null,
userStickers: [],
userStickerPacks: [],
activeStickerPackId: null,
@@ -2148,14 +2150,8 @@ export default {
return this.$t("messages.compose_hint_automatic");
},
isSyncingPropagationNode() {
- return [
- "path_requested",
- "link_establishing",
- "link_established",
- "request_sent",
- "receiving",
- "response_received",
- ].includes(this.propagationNodeStatus?.state);
+ // Mirror App chrome: only spin for user-started sync, not auto-sync.
+ return false;
},
blockedDestinations() {
return GlobalState.blockedDestinations;
@@ -2500,11 +2496,8 @@ export default {
// fetch contacts for suggestions
this.fetchContacts();
- // fetch propagation status
- this.updatePropagationNodeStatus();
- this.propagationStatusInterval = setInterval(() => {
- this.updatePropagationNodeStatus();
- }, 2000);
+ // Propagation sync chrome lives in App; this page only triggers it.
+ this.propagationStatusInterval = null;
this._scheduleOutboundSendStatusTick();
this.loadUserStickers();
@@ -2521,6 +2514,8 @@ export default {
},
beforeUnmount() {
this.scrollBottomGen += 1;
+ this._cleanupReactionPickerDrag();
+ this.closeReactionPicker();
this.teardownPeerHeaderResizeObserver();
if (this.selectedPeer) {
this.saveDraft(this.selectedPeer.destination_hash);
@@ -3611,6 +3606,11 @@ export default {
return;
}
+ if (lxmfMessage?.is_reaction && lxmfMessage.reaction_to) {
+ this.applyIncomingReaction(lxmfMessage);
+ return;
+ }
+
this.removeAllPendingOutboundPlaceholdersForPeer(lxmfMessage.destination_hash);
this.reconcileOutboundPendingPlaceholders(lxmfMessage);
@@ -4138,130 +4138,244 @@ export default {
return item ? item.lxmf_message : null;
},
reactionReactorLabel(senderHex) {
- if (!senderHex || typeof senderHex !== "string") {
- return "";
- }
- const hex = senderHex.toLowerCase();
- if (this.myLxmfAddressHash && hex === String(this.myLxmfAddressHash).toLowerCase()) {
- return this.$t("messages.reaction_you");
- }
- if (
- this.selectedPeer?.destination_hash &&
- hex === String(this.selectedPeer.destination_hash).toLowerCase()
- ) {
- return (
- this.selectedPeer.custom_display_name ??
- this.selectedPeer.display_name ??
- this.formatDestinationHash(hex)
+ try {
+ if (senderHex == null || senderHex === "") {
+ return "";
+ }
+ const hex = String(senderHex).toLowerCase();
+ if (this.myLxmfAddressHash && hex === String(this.myLxmfAddressHash).toLowerCase()) {
+ return this.$t("messages.reaction_you");
+ }
+ if (
+ this.selectedPeer?.destination_hash &&
+ hex === String(this.selectedPeer.destination_hash).toLowerCase()
+ ) {
+ return (
+ this.selectedPeer.custom_display_name ??
+ this.selectedPeer.display_name ??
+ this.formatDestinationHash(hex)
+ );
+ }
+ const conv = (this.conversations || []).find(
+ (c) => c?.destination_hash && String(c.destination_hash).toLowerCase() === hex
);
+ if (conv) {
+ return conv.custom_display_name ?? conv.display_name ?? this.formatDestinationHash(hex);
+ }
+ return this.formatDestinationHash(hex);
+ } catch (e) {
+ console.error(e);
+ return "";
}
- const conv = this.conversations.find(
- (c) => c.destination_hash && String(c.destination_hash).toLowerCase() === hex
- );
- if (conv) {
- return conv.custom_display_name ?? conv.display_name ?? this.formatDestinationHash(hex);
- }
- return this.formatDestinationHash(hex);
},
applyIncomingReaction(lxmfMessage) {
- const target = this.chatItems.find((i) => i.lxmf_message?.hash === lxmfMessage.reaction_to);
- if (!target || !target.lxmf_message) {
- return;
- }
- if (!target.lxmf_message.reactions) {
- target.lxmf_message.reactions = [];
- }
- const sender = lxmfMessage.reaction_sender || lxmfMessage.source_hash || "";
- const emoji = lxmfMessage.reaction_emoji || "";
- const dup = target.lxmf_message.reactions.some((r) => r.sender === sender && r.emoji === emoji);
- if (dup) {
- return;
+ try {
+ if (!lxmfMessage || typeof lxmfMessage !== "object") {
+ return;
+ }
+ const reactionTo = lxmfMessage.reaction_to;
+ if (!reactionTo) {
+ return;
+ }
+ const target = this.chatItems.find((i) => this._hexEqual(i?.lxmf_message?.hash, reactionTo));
+ if (!target?.lxmf_message) {
+ return;
+ }
+ if (!Array.isArray(target.lxmf_message.reactions)) {
+ target.lxmf_message.reactions = [];
+ }
+ const sender = String(lxmfMessage.reaction_sender || lxmfMessage.source_hash || "");
+ const emoji = typeof lxmfMessage.reaction_emoji === "string" ? lxmfMessage.reaction_emoji : "";
+ if (!emoji) {
+ return;
+ }
+ const senderKey = sender.toLowerCase();
+ const dup = target.lxmf_message.reactions.some(
+ (r) => String(r?.sender || "").toLowerCase() === senderKey && r?.emoji === emoji
+ );
+ if (dup) {
+ const existing = target.lxmf_message.reactions.find(
+ (r) => String(r?.sender || "").toLowerCase() === senderKey && r?.emoji === emoji
+ );
+ if (existing && !existing.reactionHash && lxmfMessage.hash) {
+ existing.reactionHash = lxmfMessage.hash;
+ }
+ return;
+ }
+ target.lxmf_message.reactions.push({
+ emoji,
+ sender,
+ reactionHash: lxmfMessage.hash || null,
+ });
+ } catch (e) {
+ console.error(e);
}
- target.lxmf_message.reactions.push({
- emoji,
- sender,
- reactionHash: lxmfMessage.hash,
- });
},
openReactionPicker(chatItem) {
+ if (!chatItem?.lxmf_message?.hash) {
+ return;
+ }
+ this._cleanupReactionPickerDrag();
this.reactionPickerPos = null;
this.reactionPickerChatItem = chatItem;
},
closeReactionPicker() {
+ this._cleanupReactionPickerDrag();
this.reactionPickerChatItem = null;
this.reactionPickerPos = null;
this.reactionDragState = null;
},
+ _cleanupReactionPickerDrag() {
+ if (typeof this.reactionDragCleanup === "function") {
+ try {
+ this.reactionDragCleanup();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ this.reactionDragCleanup = null;
+ this.reactionDragState = null;
+ },
onReactionPickerDragStart(e) {
- const evt = e.touches ? e.touches[0] : e;
- const panel = this.$refs.reactionPickerPanel;
- if (!panel) return;
- const rect = panel.getBoundingClientRect();
- this.reactionDragState = {
- startX: evt.clientX,
- startY: evt.clientY,
- originX: rect.left,
- originY: rect.top,
- };
- const onMove = (me) => {
- const mv = me.touches ? me.touches[0] : me;
- const dx = mv.clientX - this.reactionDragState.startX;
- const dy = mv.clientY - this.reactionDragState.startY;
- const panelEl = this.$refs.reactionPickerPanel;
- if (!panelEl) return;
- const pr = panelEl.getBoundingClientRect();
- const nx = this.reactionDragState.originX + dx;
- const ny = this.reactionDragState.originY + dy;
- const { left, top } = clampFloatingToViewport(nx, ny, pr.width, pr.height);
- this.reactionPickerPos = { x: left, y: top };
- };
- const onUp = () => {
- document.removeEventListener("mousemove", onMove);
- document.removeEventListener("mouseup", onUp);
- document.removeEventListener("touchmove", onMove);
- document.removeEventListener("touchend", onUp);
- };
- document.addEventListener("mousemove", onMove);
- document.addEventListener("mouseup", onUp);
- document.addEventListener("touchmove", onMove, { passive: false });
- document.addEventListener("touchend", onUp);
+ try {
+ const evt = e?.touches?.[0] || e?.changedTouches?.[0] || e;
+ const panel = this.$refs.reactionPickerPanel;
+ if (!evt || !panel || typeof panel.getBoundingClientRect !== "function") {
+ return;
+ }
+ if (typeof evt.clientX !== "number" || typeof evt.clientY !== "number") {
+ return;
+ }
+ this._cleanupReactionPickerDrag();
+ const rect = panel.getBoundingClientRect();
+ const dragState = {
+ startX: evt.clientX,
+ startY: evt.clientY,
+ originX: rect.left,
+ originY: rect.top,
+ };
+ this.reactionDragState = dragState;
+ const onMove = (me) => {
+ try {
+ if (!this.reactionDragState) {
+ return;
+ }
+ const mv = me?.touches?.[0] || me?.changedTouches?.[0] || me;
+ if (!mv || typeof mv.clientX !== "number" || typeof mv.clientY !== "number") {
+ return;
+ }
+ const dx = mv.clientX - this.reactionDragState.startX;
+ const dy = mv.clientY - this.reactionDragState.startY;
+ const panelEl = this.$refs.reactionPickerPanel;
+ if (!panelEl || typeof panelEl.getBoundingClientRect !== "function") {
+ return;
+ }
+ const pr = panelEl.getBoundingClientRect();
+ const nx = this.reactionDragState.originX + dx;
+ const ny = this.reactionDragState.originY + dy;
+ const { left, top } = clampFloatingToViewport(nx, ny, pr.width, pr.height);
+ this.reactionPickerPos = { x: left, y: top };
+ if (me?.cancelable && typeof me.preventDefault === "function") {
+ me.preventDefault();
+ }
+ } catch (moveErr) {
+ console.error(moveErr);
+ }
+ };
+ const onUp = () => {
+ this._cleanupReactionPickerDrag();
+ };
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ document.addEventListener("touchmove", onMove, { passive: false });
+ document.addEventListener("touchend", onUp);
+ document.addEventListener("touchcancel", onUp);
+ this.reactionDragCleanup = () => {
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ document.removeEventListener("touchmove", onMove);
+ document.removeEventListener("touchend", onUp);
+ document.removeEventListener("touchcancel", onUp);
+ this.reactionDragState = null;
+ this.reactionDragCleanup = null;
+ };
+ } catch (e) {
+ console.error(e);
+ this._cleanupReactionPickerDrag();
+ }
},
onReactionPickerEmojiClick(event) {
- const emoji = event.detail?.unicode;
- if (!emoji || !this.reactionPickerChatItem) {
- return;
+ try {
+ const emoji = event?.detail?.unicode;
+ if (!emoji || typeof emoji !== "string" || !this.reactionPickerChatItem) {
+ return;
+ }
+ const chatItem = this.reactionPickerChatItem;
+ this.closeReactionPicker();
+ void this.sendReactionEmojiFromMenu(chatItem, emoji);
+ } catch (e) {
+ console.error(e);
+ this.closeReactionPicker();
}
- const chatItem = this.reactionPickerChatItem;
- this.reactionPickerChatItem = null;
- this.sendReactionEmojiFromMenu(chatItem, emoji);
},
async sendReactionEmojiFromMenu(chatItem, emoji) {
- this.messageContextMenu.show = false;
- const hash = chatItem.lxmf_message?.hash;
- if (!hash || !this.selectedPeer?.destination_hash) {
- return;
- }
try {
- await window.api.post("/api/v1/lxmf-messages/reactions", {
- destination_hash: this.selectedPeer.destination_hash,
- target_message_hash: hash,
- emoji,
- });
- const sender = this.myLxmfAddressHash;
- if (!chatItem.lxmf_message.reactions) {
- chatItem.lxmf_message.reactions = [];
+ this.messageContextMenu.show = false;
+ this.closeReactionPicker();
+ if (!chatItem?.lxmf_message || typeof emoji !== "string" || !emoji) {
+ return;
+ }
+ const hash = chatItem.lxmf_message.hash;
+ const destinationHash = this.selectedPeer?.destination_hash;
+ if (!hash || !destinationHash) {
+ return;
}
- const dup = chatItem.lxmf_message.reactions.some((r) => r.sender === sender && r.emoji === emoji);
- if (!dup) {
- chatItem.lxmf_message.reactions.push({
+ const flightKey = `${String(hash).toLowerCase()}:${emoji}`;
+ if (this.reactionSendInFlight === flightKey) {
+ return;
+ }
+ this.reactionSendInFlight = flightKey;
+ try {
+ const response = await window.api.post("/api/v1/lxmf-messages/reactions", {
+ destination_hash: destinationHash,
+ target_message_hash: hash,
emoji,
- sender,
- reactionHash: null,
});
+ const sender = this.myLxmfAddressHash || "";
+ if (!Array.isArray(chatItem.lxmf_message.reactions)) {
+ chatItem.lxmf_message.reactions = [];
+ }
+ const senderKey = String(sender).toLowerCase();
+ const existing = chatItem.lxmf_message.reactions.find(
+ (r) => String(r?.sender || "").toLowerCase() === senderKey && r?.emoji === emoji
+ );
+ const reactionHash = response?.data?.lxmf_message?.hash || null;
+ if (existing) {
+ if (!existing.reactionHash && reactionHash) {
+ existing.reactionHash = reactionHash;
+ }
+ } else {
+ chatItem.lxmf_message.reactions.push({
+ emoji,
+ sender,
+ reactionHash,
+ });
+ }
+ if (response?.data?.lxmf_message?.is_reaction) {
+ this.applyIncomingReaction(response.data.lxmf_message);
+ }
+ } finally {
+ if (this.reactionSendInFlight === flightKey) {
+ this.reactionSendInFlight = null;
+ }
}
} catch (e) {
console.error(e);
- ToastUtils.error(this.$t("messages.reaction_send_failed"));
+ try {
+ ToastUtils.error(this.$t("messages.reaction_send_failed"));
+ } catch (toastErr) {
+ console.error(toastErr);
+ }
}
},
onMessageContextMenu(event, chatItem, openedFromBubble = false) {
@@ -5549,6 +5663,17 @@ export default {
return;
}
+ // Warm a stale/missing path before the blocking backend path wait.
+ // Propagated delivery does not require a peer path.
+ if (job.deliveryMethod !== "propagated") {
+ try {
+ await warmPathIfNeeded(window.api, job.destinationHash, this.peerPathSnapshot);
+ await this.refreshPeerPath({ warm: false });
+ } catch (pathError) {
+ console.error(pathError);
+ }
+ }
+
if (job.images.length === 0) {
const response = await window.api.post(`/api/v1/lxmf-messages/send`, {
delivery_method: job.deliveryMethod,
@@ -5619,6 +5744,16 @@ export default {
}
} catch (subError) {
console.error(`Failed to send image ${i + 1}:`, subError);
+ const detail =
+ subError?.response?.data?.message ||
+ subError?.message ||
+ this.$t("messages.failed_to_send");
+ ToastUtils.error(
+ this.$t("messages.failed_to_send_image", {
+ index: i + 1,
+ detail,
+ })
+ );
}
}
}
@@ -6969,10 +7104,9 @@ export default {
return;
}
- // manually mark conversation read in memory to avoid delay updating ui
+ // Optimistic UI update; roll back if the server call fails.
conversation.is_unread = false;
- // mark conversation as read on server
try {
await window.api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`);
GlobalEmitter.emit("notifications-changed");
@@ -6980,7 +7114,7 @@ export default {
GlobalState.unreadConversationsCount -= 1;
}
} catch (e) {
- // do nothing if failed to mark as read
+ conversation.is_unread = true;
console.log(e);
}
},
diff --git a/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue b/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue
index 63fddc4c..aa088e58 100644
--- a/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue
+++ b/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue
@@ -17,8 +17,8 @@
:style="{
order: isOutbound ? chipIdx + 2 : chipIdx + 1,
}"
- :title="chip.kind === 'reaction' ? cv.reactionReactorLabel(chip.reaction.sender) : ''"
- >{{ chip.kind === "more" ? `+${hiddenReactionCount}` : chip.reaction.emoji }}</span
+ :title="chip.kind === 'reaction' ? reactionTitle(chip.reaction) : ''"
+ >{{ chip.kind === "more" ? `+${hiddenReactionCount}` : chip.reaction?.emoji || "" }}</span
>
<button
v-if="showReactButton"
@@ -74,15 +74,18 @@ export default {
},
computed: {
visibleReactions() {
- return (this.reactions || []).slice(0, MAX_VISIBLE_REACTIONS);
+ const list = Array.isArray(this.reactions) ? this.reactions : [];
+ return list.filter((r) => r && typeof r === "object").slice(0, MAX_VISIBLE_REACTIONS);
},
hiddenReactionCount() {
- return Math.max(0, (this.reactions?.length ?? 0) - MAX_VISIBLE_REACTIONS);
+ const list = Array.isArray(this.reactions) ? this.reactions : [];
+ const valid = list.filter((r) => r && typeof r === "object");
+ return Math.max(0, valid.length - MAX_VISIBLE_REACTIONS);
},
reactionChips() {
const chips = this.visibleReactions.map((r, idx) => ({
kind: "reaction",
- key: r.reactionHash || `reaction-${idx}`,
+ key: r.reactionHash || `reaction-${idx}-${r.emoji || ""}-${r.sender || ""}`,
reaction: r,
}));
if (this.hiddenReactionCount > 0) {
@@ -91,5 +94,18 @@ export default {
return chips;
},
},
+ methods: {
+ reactionTitle(reaction) {
+ try {
+ if (!reaction || typeof this.cv?.reactionReactorLabel !== "function") {
+ return "";
+ }
+ return this.cv.reactionReactorLabel(reaction.sender) || "";
+ } catch (e) {
+ console.error(e);
+ return "";
+ }
+ },
+ },
};
</script>
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index bb4e76c4..9256e494 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -790,14 +790,32 @@ export default {
}
const offset = append ? this.conversations.length : 0;
- const response = await window.api.get(`/api/v1/lxmf/conversations`, {
- params: {
- ...this.buildConversationQueryParams(),
- limit: this.pageSize,
- offset: offset,
- },
- signal: myController.signal,
- });
+ let response = null;
+ let attempt = 0;
+ while (attempt < 4) {
+ try {
+ response = await window.api.get(`/api/v1/lxmf/conversations`, {
+ params: {
+ ...this.buildConversationQueryParams(),
+ limit: this.pageSize,
+ offset: offset,
+ },
+ signal: myController.signal,
+ });
+ break;
+ } catch (requestError) {
+ const status = requestError?.response?.status;
+ if (status === 503 && attempt < 3 && !myController.signal.aborted) {
+ attempt += 1;
+ await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
+ continue;
+ }
+ throw requestError;
+ }
+ }
+ if (!response) {
+ return;
+ }
const newConversations = response.data.conversations;
if (!append) {
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 68846141..e7310c91 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -291,9 +291,11 @@
</IconButton>
</template>
<template #items>
- <DropDownMenuItem @click="openNomadnetPopout">
- <MaterialDesignIcon icon-name="open-in-new" class="size-5" />
- <span>{{ $t("nomadnet.pop_out_browser") }}</span>
+ <DropDownMenuItem @click="toggleNodePageSource">
+ <MaterialDesignIcon icon-name="code-tags" class="size-5" />
+ <span>{{
+ isShowingNodePageSource ? $t("nomadnet.hide_source") : $t("app.toggle_source")
+ }}</span>
</DropDownMenuItem>
<DropDownMenuItem
v-if="showMicronRendererInMobileMenu"
@@ -336,7 +338,7 @@
<MaterialDesignIcon icon-name="refresh" class="size-5" />
</IconButton>
<IconButton
- class="nomad-icon-btn shrink-0"
+ class="nomad-icon-btn hidden lg:inline-flex shrink-0"
:title="$t('app.toggle_source')"
:class="{ 'bg-green-500/10 text-green-600 dark:text-green-400': isShowingNodePageSource }"
@click="toggleNodePageSource"
diff --git a/meshchatx/src/frontend/components/ping/PingPage.vue b/meshchatx/src/frontend/components/ping/PingPage.vue
index e55f6275..b38af0d0 100644
--- a/meshchatx/src/frontend/components/ping/PingPage.vue
+++ b/meshchatx/src/frontend/components/ping/PingPage.vue
@@ -197,7 +197,7 @@ export default {
}
// simple check to ensure destination hash is valid
- if (this.timeout == null || this.timeout < 0) {
+ if (this.timeout == null || this.timeout < 1) {
DialogUtils.alert(this.$t("ping.timeout_must_be_number"));
return;
}
diff --git a/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue b/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue
index bbfaa5bc..d4cca68d 100644
--- a/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue
+++ b/meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue
@@ -710,7 +710,13 @@ export default {
},
methods: {
async onWebsocketMessage(message) {
- const json = JSON.parse(message.data);
+ let json = null;
+ try {
+ json = JSON.parse(message.data);
+ } catch (e) {
+ console.error(e);
+ return;
+ }
switch (json.type) {
case "config": {
this.config = json.config;
@@ -725,8 +731,8 @@ export default {
this.config = response.data.config;
this.syncManagerInputsFromConfig();
} catch (e) {
- // do nothing if failed to load config
console.log(e);
+ ToastUtils.error(this.$t("common.save_failed"));
}
},
async updateConfig(config) {
@@ -751,7 +757,7 @@ export default {
this.propagationNodes = response.data.lxmf_propagation_nodes;
await this.refreshPriorityNodePaths();
} catch {
- // do nothing if failed to load
+ ToastUtils.error(this.$t("tools.propagation_nodes.load_failed"));
}
},
async usePropagationNode(destination_hash) {
@@ -773,7 +779,7 @@ export default {
async restartLocalPropagationNode() {
try {
await window.api.post("/api/v1/lxmf/propagation-node/restart");
- ToastUtils.success("Local propagation node restarted");
+ ToastUtils.success(this.$t("tools.propagation_nodes.local_restarted"));
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
await this.refreshPriorityNodePaths();
} catch {
@@ -783,7 +789,7 @@ export default {
async stopLocalPropagationNode() {
try {
await window.api.post("/api/v1/lxmf/propagation-node/stop");
- ToastUtils.success("Local propagation node stopped");
+ ToastUtils.success(this.$t("tools.propagation_nodes.local_stopped"));
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
await this.refreshPriorityNodePaths();
} catch {
@@ -796,7 +802,7 @@ export default {
if (!didUpdate) {
return;
}
- ToastUtils.success("Local propagation node started");
+ ToastUtils.success(this.$t("tools.propagation_nodes.local_started"));
await Promise.all([this.getConfig(), this.loadPropagationNodes()]);
await this.refreshPriorityNodePaths();
} catch {
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index 33c47b5f..c7f8d3c8 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -340,7 +340,9 @@
<MaterialDesignIcon icon-name="magnify" class="size-5" />
</button>
<button
+ v-if="smUp"
type="button"
+ data-testid="relay-popout"
:class="btnIcon"
:title="$t('relay_chat.popout_channel')"
@click="popoutChannel"
diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index f47f5a0c..73353e32 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -627,7 +627,8 @@ export default {
const f = event.target.files?.[0];
event.target.value = "";
if (!f) return;
- this.sendFilePath = f.name;
+ // Browsers only expose the basename; require an explicit full path.
+ this.sendFilePath = "";
DialogUtils.alert(this.$t("rncp.web_path_hint"));
},
async pickFetchSaveDirectory() {
@@ -725,9 +726,17 @@ export default {
this.sendInProgress = false;
}
},
- cancelSend() {
+ async cancelSend() {
+ const transferId = this.sendTransferId;
this.sendInProgress = false;
this.sendProgress = 0;
+ try {
+ await window.api.post("/api/v1/rncp/cancel", {
+ transfer_id: transferId || undefined,
+ });
+ } catch (e) {
+ console.error(e);
+ }
},
async fetchFile() {
if (!this.fetchDestinationHash || this.fetchDestinationHash.length !== 32) {
@@ -773,9 +782,17 @@ export default {
this.fetchInProgress = false;
}
},
- cancelFetch() {
+ async cancelFetch() {
+ const transferId = this.fetchTransferId;
this.fetchInProgress = false;
this.fetchProgress = 0;
+ try {
+ await window.api.post("/api/v1/rncp/cancel", {
+ transfer_id: transferId || undefined,
+ });
+ } catch (e) {
+ console.error(e);
+ }
},
async startListen() {
const allowedHashes = this.listenAllowedHashes
diff --git a/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue b/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
index 80251c02..679de397 100644
--- a/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
+++ b/meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
@@ -42,7 +42,7 @@
</div>
<div>
<label class="glass-label">{{ $t("rnprobe.number_of_probes") }}</label>
- <input v-model="probes" type="number" min="1" max="100" class="input-field" />
+ <input v-model="probes" type="number" min="1" max="50" class="input-field" />
</div>
<div>
<label class="glass-label">{{ $t("rnprobe.wait_between_probes") }}</label>
diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
index 1740dac6..f0124771 100644
--- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
+++ b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
@@ -58,7 +58,13 @@
class="rounded-xl border border-purple-200/80 bg-purple-50/90 p-4 text-purple-900 dark:border-purple-800/50 dark:bg-purple-950/30 dark:text-purple-100"
>
<div class="flex flex-wrap items-center justify-between gap-2 font-semibold">
- <span>Blackhole: {{ blackholeEnabled ? "Publishing" : "Active" }}</span>
+ <span>{{
+ $t("rnstatus.blackhole_label", {
+ state: blackholeEnabled
+ ? $t("rnstatus.blackhole_publishing")
+ : $t("rnstatus.blackhole_inactive"),
+ })
+ }}</span>
<span class="text-sm font-normal opacity-90">
{{ formatInt(blackholeCount) }} Identities
</span>
diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
index 3fc6ca7d..eb0b41db 100644
--- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
+++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
@@ -44,7 +44,7 @@
<input
ref="identityFileInput"
type="file"
- accept=".identity,.bin,.key"
+ accept=".bin,.key,.identity,application/octet-stream,*/*"
class="hidden"
@change="onIdentityRestoreFileChange"
/>
@@ -369,15 +369,21 @@
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("identities.import_hint") }}</p>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("identities.import_key_only_hint") }}
+ </p>
<button
type="button"
class="w-full secondary-chip justify-center"
- @click="
- $refs.identityFileInput?.click();
- showImportModal = false;
- "
+ :disabled="identityRestoreInProgress"
+ @click="$refs.identityFileInput?.click()"
>
- <MaterialDesignIcon icon-name="upload" class="size-4" />
+ <MaterialDesignIcon
+ v-if="identityRestoreInProgress"
+ icon-name="loading"
+ class="size-4 animate-spin"
+ />
+ <MaterialDesignIcon v-else icon-name="upload" class="size-4" />
{{ $t("identities.upload_key_file") }}
</button>
<div class="border-t border-gray-200 dark:border-zinc-700 pt-4 space-y-3">
@@ -389,8 +395,9 @@
rows="3"
class="input-field font-mono text-xs w-full"
:placeholder="$t('identities.paste_base32_placeholder')"
+ :disabled="identityRestoreInProgress"
/>
- <div v-if="identityRestoreError" class="text-sm text-red-600 dark:text-red-400">
+ <div v-if="identityRestoreError" role="alert" class="text-sm text-red-600 dark:text-red-400">
{{ identityRestoreError }}
</div>
<div v-if="identityRestoreMessage" class="text-sm text-green-600 dark:text-green-400">
@@ -541,14 +548,50 @@ export default {
},
onIdentityRestoreFileChange(event) {
const files = event.target.files;
- if (files?.[0]) {
- this.identityRestoreFile = files[0];
- this.identityRestoreError = "";
- this.identityRestoreMessage = "";
- this.restoreIdentityFile();
+ const file = files?.[0] || null;
+ this.identityRestoreError = "";
+ this.identityRestoreMessage = "";
+ if (!file) {
+ event.target.value = "";
+ return;
+ }
+ if (file.size === 0) {
+ this.identityRestoreError = this.$t("identities.identity_restore_empty_file");
+ ToastUtils.error(this.identityRestoreError);
+ event.target.value = "";
+ return;
+ }
+ if (file.size > 65536) {
+ this.identityRestoreError = this.$t("identities.identity_restore_file_too_large");
+ ToastUtils.error(this.identityRestoreError);
+ event.target.value = "";
+ return;
}
+ this.identityRestoreFile = file;
+ this.restoreIdentityFile();
event.target.value = "";
},
+ normalizeBase32(value) {
+ return String(value || "").replace(/\s+/g, "");
+ },
+ async maybeSwitchToRestoredIdentity(identity) {
+ if (!identity?.hash) {
+ return;
+ }
+ const switchNow = await DialogUtils.confirm(
+ this.$t("identities.switch_after_restore_confirm", {
+ name: identity.display_name || identity.hash,
+ })
+ );
+ if (!switchNow) {
+ return;
+ }
+ await this.switchIdentity({
+ hash: identity.hash,
+ display_name: identity.display_name || identity.hash,
+ is_current: false,
+ });
+ },
async restoreIdentityFile() {
if (this.identityRestoreInProgress || !this.identityRestoreFile) return;
this.identityRestoreInProgress = true;
@@ -560,29 +603,42 @@ export default {
const response = await window.api.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- this.identityRestoreMessage = response.data?.message ?? this.$t("identities.identity_restored");
+ const message = response.data?.message ?? this.$t("identities.identity_restored");
+ this.identityRestoreMessage = message;
this.identityRestoreFile = null;
+ ToastUtils.success(message);
+ await this.getIdentities();
this.showImportModal = false;
- } catch {
- this.identityRestoreError = this.$t("identities.identity_restore_failed");
+ await this.maybeSwitchToRestoredIdentity(response.data?.identity);
+ } catch (e) {
+ const msg = e?.response?.data?.message || this.$t("identities.identity_restore_failed");
+ this.identityRestoreError = msg;
+ ToastUtils.error(msg);
} finally {
this.identityRestoreInProgress = false;
}
},
async restoreIdentityBase32() {
- if (this.identityRestoreInProgress || !this.identityRestoreBase32?.trim()) return;
+ const normalized = this.normalizeBase32(this.identityRestoreBase32);
+ if (this.identityRestoreInProgress || !normalized) return;
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const response = await window.api.post("/api/v1/identity/restore", {
- base32: this.identityRestoreBase32.trim(),
+ base32: normalized,
});
- this.identityRestoreMessage = response.data?.message ?? this.$t("identities.identity_restored");
+ const message = response.data?.message ?? this.$t("identities.identity_restored");
+ this.identityRestoreMessage = message;
this.identityRestoreBase32 = "";
+ ToastUtils.success(message);
+ await this.getIdentities();
this.showImportModal = false;
- } catch {
- this.identityRestoreError = this.$t("identities.identity_restore_failed");
+ await this.maybeSwitchToRestoredIdentity(response.data?.identity);
+ } catch (e) {
+ const msg = e?.response?.data?.message || this.$t("identities.identity_restore_failed");
+ this.identityRestoreError = msg;
+ ToastUtils.error(msg);
} finally {
this.identityRestoreInProgress = false;
}
diff --git a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
index d839f64d..e65a50b2 100644
--- a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
+++ b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
@@ -209,12 +209,23 @@
</label>
<label class="block text-sm text-gray-800 dark:text-gray-200 space-y-1">
<span>{{ $t("plugins.sideband.path") }}</span>
- <input
- v-model="sidebandConfig.command_plugins_path"
- type="text"
- class="w-full rounded-md border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-1.5 text-sm"
- :disabled="!sidebandConfig.service_plugins_enabled"
- />
+ <div class="flex flex-col sm:flex-row gap-2">
+ <input
+ v-model="sidebandConfig.command_plugins_path"
+ type="text"
+ class="w-full rounded-md border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-1.5 text-sm min-w-0"
+ :disabled="!sidebandConfig.service_plugins_enabled"
+ />
+ <button
+ type="button"
+ class="px-3 py-1.5 rounded-md border border-gray-300 dark:border-zinc-600 text-sm shrink-0 min-h-[44px]"
+ :disabled="!sidebandConfig.service_plugins_enabled || sidebandBusy"
+ :title="$t('plugins.sideband.browse_title')"
+ @click="pickSidebandPluginsDirectory"
+ >
+ {{ $t("plugins.sideband.browse") }}
+ </button>
+ </div>
</label>
<div class="flex flex-wrap gap-2">
<button
@@ -277,6 +288,9 @@
import SettingsSectionBlock from "./SettingsSectionBlock.vue";
import PluginInstallDialog from "./PluginInstallDialog.vue";
import ToastUtils from "../../js/ToastUtils";
+import DialogUtils from "../../js/DialogUtils";
+import ElectronUtils from "../../js/ElectronUtils";
+import AndroidBridge from "../../js/rnode/AndroidBridge";
import { permissionLabel } from "../../js/plugins/pluginPermissions.js";
import { pluginHost } from "../../js/plugins/PluginHost.js";
import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
@@ -357,6 +371,37 @@ export default {
}
}
},
+ async pickSidebandPluginsDirectory() {
+ if (!this.sidebandConfig.service_plugins_enabled) {
+ return;
+ }
+ const picked = await ElectronUtils.pickDirectory();
+ if (picked) {
+ this.sidebandConfig.command_plugins_path = picked;
+ ToastUtils.success(this.$t("plugins.sideband.path_picked"));
+ return;
+ }
+ if (ElectronUtils.isElectron()) {
+ return;
+ }
+ const android = new AndroidBridge();
+ let initial = this.sidebandConfig.command_plugins_path || "";
+ if (android.isAvailable()) {
+ const suggested = android.getSidebandPluginsDefaultPath();
+ if (suggested && !initial) {
+ initial = suggested;
+ }
+ }
+ const entered = await DialogUtils.prompt(
+ initial
+ ? `${this.$t("plugins.sideband.path_prompt")}\n${initial}`
+ : this.$t("plugins.sideband.path_prompt")
+ );
+ if (entered != null && String(entered).trim()) {
+ this.sidebandConfig.command_plugins_path = String(entered).trim();
+ ToastUtils.success(this.$t("plugins.sideband.path_picked"));
+ }
+ },
async saveSidebandConfig() {
this.sidebandBusy = true;
try {
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 49f3c1d0..5b40c562 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -1626,8 +1626,8 @@
<header class="settings-section__header">
<div>
<div class="settings-section__eyebrow">{{ $t("app.settings_map_eyebrow") }}</div>
- <h2>{{ $t("app.location") }}</h2>
- <p>{{ $t("app.location_manage_desc") }}</p>
+ <h2>{{ $t("app.map_settings_title") }}</h2>
+ <p>{{ $t("app.map_settings_desc") }}</p>
</div>
</header>
<div class="settings-section__body space-y-4">
@@ -1722,6 +1722,161 @@
/>
</div>
</div>
+
+ <div class="space-y-2 border-t border-gray-200 dark:border-zinc-800 pt-4">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_defaults_heading") }}
+ </div>
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_default_lat") }}
+ </div>
+ <input
+ v-model="config.map_default_lat"
+ type="text"
+ class="input-field"
+ @input="
+ updateConfig(
+ { map_default_lat: config.map_default_lat },
+ 'map_default_lat'
+ )
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_default_lon") }}
+ </div>
+ <input
+ v-model="config.map_default_lon"
+ type="text"
+ class="input-field"
+ @input="
+ updateConfig(
+ { map_default_lon: config.map_default_lon },
+ 'map_default_lon'
+ )
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_default_zoom") }}
+ </div>
+ <input
+ v-model.number="config.map_default_zoom"
+ type="number"
+ class="input-field"
+ @input="
+ updateConfig(
+ { map_default_zoom: config.map_default_zoom },
+ 'map_default_zoom'
+ )
+ "
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="space-y-2 border-t border-gray-200 dark:border-zinc-800 pt-4">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_tiles_heading") }}
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_tile_server_url") }}
+ </div>
+ <input
+ v-model="config.map_tile_server_url"
+ type="text"
+ class="input-field"
+ @input="
+ updateConfig(
+ { map_tile_server_url: config.map_tile_server_url },
+ 'map_tile_server_url'
+ )
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_nominatim_api_url") }}
+ </div>
+ <input
+ v-model="config.map_nominatim_api_url"
+ type="text"
+ class="input-field"
+ @input="
+ updateConfig(
+ { map_nominatim_api_url: config.map_nominatim_api_url },
+ 'map_nominatim_api_url'
+ )
+ "
+ />
+ </div>
+ <label class="setting-toggle">
+ <Toggle
+ v-model="config.map_offline_enabled"
+ @update:model-value="
+ updateConfig(
+ { map_offline_enabled: config.map_offline_enabled },
+ 'map_offline_enabled'
+ )
+ "
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("app.map_offline_enabled")
+ }}</span>
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ v-model="config.map_tile_cache_enabled"
+ @update:model-value="
+ updateConfig(
+ { map_tile_cache_enabled: config.map_tile_cache_enabled },
+ 'map_tile_cache_enabled'
+ )
+ "
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("app.map_tile_cache_enabled")
+ }}</span>
+ </span>
+ </label>
+ </div>
+
+ <div class="space-y-3 border-t border-gray-200 dark:border-zinc-800 pt-4">
+ <div>
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.map_overlay_limits_heading") }}
+ </div>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.map_overlay_limits_desc") }}
+ </div>
+ </div>
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div v-for="field in mapOverlayLimitFields" :key="field.key" class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t(field.labelKey) }}
+ </div>
+ <input
+ v-model.number="config[field.key]"
+ type="number"
+ class="input-field"
+ :min="field.min"
+ :max="field.max"
+ @change="onMapOverlayLimitChange(field.key)"
+ />
+ <div class="text-[10px] text-gray-500">
+ {{ field.min }} .. {{ field.max }}
+ </div>
+ </div>
+ </div>
+ </div>
</div>
</section>
@@ -3035,25 +3190,34 @@
<!-- Keyboard Shortcuts -->
<div v-show="showSection('shortcuts')">
<section class="settings-section">
- <div class="settings-section__header">
- <div class="flex items-center gap-3">
+ <button
+ type="button"
+ class="settings-section__header w-full text-left"
+ :aria-expanded="shortcutsExpanded"
+ @click="shortcutsExpanded = !shortcutsExpanded"
+ >
+ <div class="flex items-center gap-3 w-full min-w-0">
<div
- class="p-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-xl"
+ class="p-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-xl shrink-0"
>
<MaterialDesignIcon icon-name="keyboard-outline" class="size-6" />
</div>
- <div>
- <h2>Keyboard Shortcuts</h2>
- <p>Customize your workflow with quick keyboard actions</p>
+ <div class="min-w-0 flex-1">
+ <h2>{{ $t("settings.keyboard_shortcuts_title") }}</h2>
+ <p>{{ $t("settings.keyboard_shortcuts_description") }}</p>
</div>
+ <MaterialDesignIcon
+ :icon-name="shortcutsExpanded ? 'chevron-up' : 'chevron-down'"
+ class="size-6 shrink-0 text-gray-500 dark:text-zinc-400"
+ />
</div>
- </div>
- <div class="settings-section__body">
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ </button>
+ <div v-show="shortcutsExpanded" class="settings-section__body">
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<div
v-for="shortcut in KeyboardShortcuts.getDefaultShortcuts()"
:key="shortcut.action"
- class="bg-gray-50/50 dark:bg-zinc-800/30 rounded-2xl p-5 border border-gray-100 dark:border-zinc-800"
+ class="bg-gray-50/50 dark:bg-zinc-800/30 rounded-2xl p-4 sm:p-5 border border-gray-100 dark:border-zinc-800"
>
<div class="flex items-center justify-between mb-3">
<span
@@ -3153,6 +3317,7 @@ export default {
GlobalState,
ElectronUtils,
KeyboardShortcuts,
+ shortcutsExpanded: typeof window !== "undefined" ? window.innerWidth >= 1024 : true,
config: {
display_name: "",
identity_hash: "",
@@ -3209,6 +3374,23 @@ export default {
location_manual_lat: "0.0",
location_manual_lon: "0.0",
location_manual_alt: "0.0",
+ map_default_lat: "0.0",
+ map_default_lon: "0.0",
+ map_default_zoom: 2,
+ map_tile_server_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ map_nominatim_api_url: "https://nominatim.openstreetmap.org",
+ map_offline_enabled: false,
+ map_tile_cache_enabled: true,
+ map_overlay_max_bytes: 8 * 1024 * 1024,
+ map_overlay_max_features: 50000,
+ map_overlay_max_kmz_uncompressed_bytes: 16 * 1024 * 1024,
+ map_overlay_max_sources: 64,
+ map_overlay_max_concurrent_jobs: 2,
+ map_overlay_path_timeout_seconds: 30,
+ map_overlay_transfer_timeout_seconds: 120,
+ map_overlay_job_timeout_seconds: 300,
+ map_overlay_max_retries: 3,
+ map_overlay_retry_delay_seconds: 2,
telemetry_enabled: false,
gitea_base_url: "",
csp_extra_connect_src: "",
@@ -3416,6 +3598,70 @@ export default {
const c = this.config?.lxmf_inbound_stamp_cost;
return (typeof c === "number" ? c : Number(c) || 0) > 0;
},
+ mapOverlayLimitFields() {
+ return [
+ {
+ key: "map_overlay_max_bytes",
+ labelKey: "app.map_overlay_max_bytes",
+ min: 65536,
+ max: 67108864,
+ },
+ {
+ key: "map_overlay_max_features",
+ labelKey: "app.map_overlay_max_features",
+ min: 100,
+ max: 500000,
+ },
+ {
+ key: "map_overlay_max_kmz_uncompressed_bytes",
+ labelKey: "app.map_overlay_max_kmz_uncompressed_bytes",
+ min: 262144,
+ max: 134217728,
+ },
+ {
+ key: "map_overlay_max_sources",
+ labelKey: "app.map_overlay_max_sources",
+ min: 1,
+ max: 256,
+ },
+ {
+ key: "map_overlay_max_concurrent_jobs",
+ labelKey: "app.map_overlay_max_concurrent_jobs",
+ min: 1,
+ max: 8,
+ },
+ {
+ key: "map_overlay_path_timeout_seconds",
+ labelKey: "app.map_overlay_path_timeout_seconds",
+ min: 5,
+ max: 300,
+ },
+ {
+ key: "map_overlay_transfer_timeout_seconds",
+ labelKey: "app.map_overlay_transfer_timeout_seconds",
+ min: 15,
+ max: 600,
+ },
+ {
+ key: "map_overlay_job_timeout_seconds",
+ labelKey: "app.map_overlay_job_timeout_seconds",
+ min: 30,
+ max: 1800,
+ },
+ {
+ key: "map_overlay_max_retries",
+ labelKey: "app.map_overlay_max_retries",
+ min: 0,
+ max: 10,
+ },
+ {
+ key: "map_overlay_retry_delay_seconds",
+ labelKey: "app.map_overlay_retry_delay_seconds",
+ min: 1,
+ max: 120,
+ },
+ ];
+ },
isMeshChatXAndroid() {
return (
typeof window !== "undefined" &&
@@ -3852,6 +4098,18 @@ export default {
console.log(e);
}
},
+ async onMapOverlayLimitChange(key) {
+ const field = this.mapOverlayLimitFields.find((f) => f.key === key);
+ let value = Number(this.config[key]);
+ if (!Number.isFinite(value)) {
+ return;
+ }
+ if (field) {
+ value = Math.max(field.min, Math.min(field.max, Math.trunc(value)));
+ this.config[key] = value;
+ }
+ await this.updateConfig({ [key]: value }, key);
+ },
syncLxmfTransferLimitInputs() {
const incoming = syncIncomingDeliveryFieldsFromBytes(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.lxmfIncomingDeliveryPreset = incoming.preset;
diff --git a/meshchatx/src/frontend/components/tools/RNPathPage.vue b/meshchatx/src/frontend/components/tools/RNPathPage.vue
index 38b6a348..af48d09c 100644
--- a/meshchatx/src/frontend/components/tools/RNPathPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNPathPage.vue
@@ -382,6 +382,10 @@ export default {
currentPage() {
this.refreshTable();
},
+ itemsPerPage() {
+ this.currentPage = 1;
+ this.refreshTable();
+ },
},
mounted() {
this.refreshAll();
@@ -430,17 +434,26 @@ export default {
this.unresponsiveItems = res.unresponsive;
} catch (e) {
console.error(e);
+ ToastUtils.error(this.$t("tools.rnpath.failed_fetch"));
} finally {
this.isLoading = false;
}
},
async fetchPathTable() {
+ let hops = undefined;
+ if (this.filterHops !== null && this.filterHops !== "") {
+ const parsed = Number(this.filterHops);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(this.$t("tools.rnpath.invalid_hops"));
+ }
+ hops = parsed;
+ }
const params = {
page: this.currentPage,
limit: this.itemsPerPage,
search: this.searchQuery || undefined,
interface: this.filterInterface || undefined,
- hops: this.filterHops !== null ? this.filterHops : undefined,
+ hops,
};
const res = await window.api.get("/api/v1/rnpath/table", { params });
return res.data;
@@ -456,7 +469,7 @@ export default {
return "UNKNOWN";
},
async dropPath(hash) {
- if (!(await DialogUtils.confirm(`Are you sure you want to drop the path to ${hash}?`))) {
+ if (!(await DialogUtils.confirm(this.$t("tools.rnpath.drop_confirm", { hash })))) {
return;
}
try {
@@ -474,15 +487,14 @@ export default {
async requestPath() {
try {
await window.api.post("/api/v1/rnpath/request", { destination_hash: this.requestHash });
- ToastUtils.success(`Path requested for ${this.requestHash.substring(0, 8)}...`);
+ ToastUtils.success(this.$t("tools.rnpath.path_requested", { hash: this.requestHash.substring(0, 8) }));
this.requestHash = "";
- // Path requests take time, don't refresh immediately
} catch {
ToastUtils.error(this.$t("tools.rnpath.failed_request"));
}
},
async dropAllVia() {
- if (!(await DialogUtils.confirm(`Drop ALL paths via ${this.dropViaHash}?`))) {
+ if (!(await DialogUtils.confirm(this.$t("tools.rnpath.drop_via_confirm", { hash: this.dropViaHash })))) {
return;
}
try {
diff --git a/meshchatx/src/frontend/index.html b/meshchatx/src/frontend/index.html
index bc1622f7..139800c5 100644
--- a/meshchatx/src/frontend/index.html
+++ b/meshchatx/src/frontend/index.html
@@ -71,9 +71,25 @@
opacity: 0.85;
font-size: 0.875rem;
}
+ html,
+ body {
+ margin: 0;
+ min-height: 100%;
+ background-color: #f8fafc;
+ }
+ @media (prefers-color-scheme: dark) {
+ html,
+ body {
+ background-color: #09090b;
+ }
+ }
+ #app {
+ min-height: 100dvh;
+ background-color: inherit;
+ }
</style>
</head>
- <body class="bg-gray-100">
+ <body>
<noscript>
<div
style="
diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index ddf7abc1..2c597ab1 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -15,6 +15,8 @@ const globalState = reactive({
blockedDestinations: [],
modifiedInterfaceNames: new Set(),
hasPendingInterfaceChanges: false,
+ networkDegraded: false,
+ networkDegradedError: null,
config: {
show_unknown_contact_banner: true,
banished_effect_enabled: true,
diff --git a/meshchatx/src/frontend/js/Utils.js b/meshchatx/src/frontend/js/Utils.js
index f294231b..5ef750db 100644
--- a/meshchatx/src/frontend/js/Utils.js
+++ b/meshchatx/src/frontend/js/Utils.js
@@ -2,9 +2,13 @@ import dayjs from "dayjs";
class Utils {
static formatDestinationHash(destinationHashHex) {
+ if (destinationHashHex == null || destinationHashHex === "") {
+ return "<>";
+ }
+ const hex = String(destinationHashHex);
const bytesPerSide = 4;
- const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
- const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
+ const leftSide = hex.substring(0, bytesPerSide * 2);
+ const rightSide = hex.substring(Math.max(0, hex.length - bytesPerSide * 2));
return `<${leftSide}...${rightSide}>`;
}
diff --git a/meshchatx/src/frontend/js/WebSocketConnection.js b/meshchatx/src/frontend/js/WebSocketConnection.js
index a15d665e..e782fe3a 100644
--- a/meshchatx/src/frontend/js/WebSocketConnection.js
+++ b/meshchatx/src/frontend/js/WebSocketConnection.js
@@ -281,7 +281,9 @@ class WebSocketConnection {
send(message) {
if (this.ws != null && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message);
+ return true;
}
+ return false;
}
ping() {
diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js
index 4f0f9ac9..737e22c5 100644
--- a/meshchatx/src/frontend/js/lxmfReactions.js
+++ b/meshchatx/src/frontend/js/lxmfReactions.js
@@ -27,7 +27,7 @@ export function mergeLxmfReactionRowsIntoMessages(messages) {
const parents = [];
const reactions = [];
for (const m of messages) {
- if (!m) {
+ if (!m || typeof m !== "object") {
continue;
}
if (m.is_reaction) {
@@ -46,14 +46,20 @@ export function mergeLxmfReactionRowsIntoMessages(messages) {
if (!parent) {
continue;
}
- const sender = r.reaction_sender || r.source_hash || "";
- const emoji = r.reaction_emoji || "";
- const dup = parent.reactions.some((x) => x.sender === sender && x.emoji === emoji);
+ const sender = String(r.reaction_sender || r.source_hash || "");
+ const emoji = typeof r.reaction_emoji === "string" ? r.reaction_emoji : "";
+ if (!emoji) {
+ continue;
+ }
+ const senderKey = sender.toLowerCase();
+ const dup = parent.reactions.some(
+ (x) => String(x.sender || "").toLowerCase() === senderKey && x.emoji === emoji
+ );
if (!dup) {
parent.reactions.push({
emoji,
sender,
- reactionHash: r.hash,
+ reactionHash: r.hash || null,
});
}
}
diff --git a/meshchatx/src/frontend/js/networkStartupWait.js b/meshchatx/src/frontend/js/networkStartupWait.js
index c3696c85..aa9afd54 100644
--- a/meshchatx/src/frontend/js/networkStartupWait.js
+++ b/meshchatx/src/frontend/js/networkStartupWait.js
@@ -12,7 +12,7 @@ export const STARTUP_STAGE_LABELS = {
/**
* Interpret a /api/v1/status JSON body for boot gating.
* @param {unknown} data
- * @returns {{ kind: "ready" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
+ * @returns {{ kind: "ready" | "degraded" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
*/
export function interpretStartupStatus(data) {
if (!data || typeof data !== "object") {
@@ -21,6 +21,15 @@ export function interpretStartupStatus(data) {
const status = data.status;
const stage = typeof data.stage === "string" ? data.stage : undefined;
if (status === "failed") {
+ // HTTP is up and the backend marked UI as usable: mount the app in
+ // degraded mode so interfaces/settings remain reachable for recovery.
+ if (data.ui_ready === true || data.network_degraded === true) {
+ return {
+ kind: "degraded",
+ stage: stage || "failed",
+ error: typeof data.error === "string" ? data.error : undefined,
+ };
+ }
return {
kind: "failed",
stage: stage || "failed",
@@ -42,7 +51,7 @@ export function interpretStartupStatus(data) {
}
/**
- * Poll /api/v1/status until the network stack is ready.
+ * Poll /api/v1/status until the network stack is ready or degraded-but-usable.
* @param {{
* fetchImpl?: typeof fetch,
* now?: () => number,
@@ -50,9 +59,10 @@ export function interpretStartupStatus(data) {
* timeoutMs?: number,
* onLine?: (text: string) => void,
* onErrorState?: () => void,
+ * onDegraded?: (error?: string) => void,
* statusUrl?: string,
* }} [options]
- * @returns {Promise<boolean>}
+ * @returns {Promise<"ready" | "degraded" | false>}
*/
export async function waitForNetworkReady(options = {}) {
const fetchImpl = options.fetchImpl || fetch;
@@ -61,6 +71,7 @@ export async function waitForNetworkReady(options = {}) {
const timeoutMs = options.timeoutMs ?? 120000;
const onLine = options.onLine || (() => {});
const onErrorState = options.onErrorState || (() => {});
+ const onDegraded = options.onDegraded || (() => {});
const statusUrl = options.statusUrl || "/api/v1/status";
const deadline = now() + timeoutMs;
@@ -71,13 +82,18 @@ export async function waitForNetworkReady(options = {}) {
if (response.ok) {
const data = await response.json();
const interpreted = interpretStartupStatus(data);
+ if (interpreted.kind === "degraded") {
+ onLine(interpreted.error || "Mesh network unavailable. Opening recovery UI…");
+ onDegraded(interpreted.error);
+ return "degraded";
+ }
if (interpreted.kind === "failed") {
onLine(interpreted.error || "Network startup failed.");
onErrorState();
return false;
}
if (interpreted.kind === "ready") {
- return true;
+ return "ready";
}
if (interpreted.kind === "starting") {
onLine(interpreted.label || "Getting things ready…");
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index 3c2aee6b..2c0ba6fb 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -329,9 +329,18 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.location",
"app.location_manage_desc",
"app.location_source",
+ "app.map_settings_title",
+ "app.map_settings_desc",
+ "app.map_overlay_limits_heading",
+ "app.map_overlay_max_bytes",
+ "app.map_overlay_max_features",
"Map",
"Location",
"GPS",
+ "overlay",
+ "KMZ",
+ "KML",
+ "GeoJSON",
"manual",
"latitude",
"longitude",
diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.js b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
index 954c0593..ca370e0c 100644
--- a/meshchatx/src/frontend/js/rnode/AndroidBridge.js
+++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
@@ -112,6 +112,13 @@ export default class AndroidBridge {
return safeCall(() => this.bridge.getPlatform(), null);
}
+ getSidebandPluginsDefaultPath() {
+ if (!this.bridge || typeof this.bridge.getSidebandPluginsDefaultPath !== "function") {
+ return null;
+ }
+ return safeCall(() => this.bridge.getSidebandPluginsDefaultPath(), null);
+ }
+
/**
* Opens the system share sheet with the installed APK (Bluetooth, Nearby Share, etc.).
* No-op when bridge or method is missing.
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index abfacb98..4115e3d5 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "Telemetrie-Vertrauen entziehen",
"telemetry_trust_grant": "Für Telemetrie vertrauen",
"location_manage_desc": "Verwalten Sie, wie Ihr Standort geteilt wird.",
+ "map_settings_title": "Karte",
+ "map_settings_desc": "Kartenvorgaben, Kacheln, Offline-Modus und Limits für Remote-Overlays.",
+ "map_defaults_heading": "Kartenvorgaben",
+ "map_tiles_heading": "Kacheln und Offline",
+ "map_overlay_limits_heading": "Limits für Remote-Overlays",
+ "map_overlay_limits_desc": "Schutzgrenzen für NomadNet- und RNGit-Importe von KMZ/KML/GeoJSON. Werte werden auf sichere Bereiche begrenzt.",
+ "map_overlay_max_bytes": "Max. Overlay-Dateigröße (Bytes)",
+ "map_overlay_max_features": "Max. Features pro Overlay",
+ "map_overlay_max_kmz_uncompressed_bytes": "Max. unkomprimierte KMZ-Größe (Bytes)",
+ "map_overlay_max_sources": "Max. Overlay-Quellen",
+ "map_overlay_max_concurrent_jobs": "Max. gleichzeitige Overlay-Jobs",
+ "map_overlay_path_timeout_seconds": "Timeout für Pfadsuche (Sekunden)",
+ "map_overlay_transfer_timeout_seconds": "Timeout für Übertragung (Sekunden)",
+ "map_overlay_job_timeout_seconds": "Timeout für Job (Sekunden)",
+ "map_overlay_max_retries": "Max. Abrufversuche",
+ "map_overlay_retry_delay_seconds": "Basisverzögerung für Wiederholung (Sekunden)",
+ "map_default_lat": "Standard-Breitengrad",
+ "map_default_lon": "Standard-Längengrad",
+ "map_default_zoom": "Standard-Zoom",
+ "map_tile_server_url": "Kachelserver-URL",
+ "map_nominatim_api_url": "Nominatim-API-URL",
+ "map_offline_enabled": "Offline-MBTiles aktiviert",
+ "map_tile_cache_enabled": "Kachelcache aktiviert",
"restart_rns": "RNS neu starten",
"flood_protection": "Überflutungsschutz",
"flood_protection_description": "Erhöht automatisch die eingehenden Stempelkosten, wenn zu viele Nachrichten pro Minute aus vielen Quellen empfangen werden. Dies macht koordinierte Spam-Angriffe rechenintensiv, während normale Unterhaltungen erschwinglich bleiben.",
@@ -456,7 +479,12 @@
"rpc_key_show": "RPC-Schlüssel anzeigen",
"rpc_key_hide": "RPC-Schlüssel verbergen",
"refresh_community_interfaces": "Von directory.rns.recipes aktualisieren",
- "refresh_community_interfaces_busy": "Wird aktualisiert…"
+ "refresh_community_interfaces_busy": "Wird aktualisiert…",
+ "network_degraded": "Mesh-Netzwerk nicht verfügbar. Die App läuft weiter, damit Sie Schnittstellen reparieren können, ohne Daten zu löschen.",
+ "recover_network": "Netzwerk erneut versuchen",
+ "open_interfaces": "Schnittstellen öffnen",
+ "network_recovered": "Netzwerkstapel wiederhergestellt",
+ "network_recover_failed": "Netzwerkstapel konnte nicht wiederhergestellt werden. Prüfen Sie die Schnittstellen und versuchen Sie es erneut."
},
"common": {
"open": "Öffnen",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Pakete über RNS-Links senden",
"managers.rnsLink.close": "RNS-Links schließen",
"storage.isolated": "Isolierten Plugin-Speicher verwenden",
- "network.fetch": "Ausgehende Internet-HTTP-Anfragen stellen"
+ "network.fetch": "Ausgehende Internet-HTTP-Anfragen stellen",
+ "managers.debugLog.read": "Anwendungs-Debug-Logs lesen",
+ "managers.bugReport.status": "Status des Bug-Report-Collectors lesen",
+ "managers.bugReport.listCollectors": "Gehörte mcx-bugs-v1-Collector auflisten",
+ "managers.bugReport.listReports": "Empfangene Bug-Reports auflisten",
+ "managers.bugReport.preview": "Geschwärzte Debug-Logs für Bug-Reports Vorschau",
+ "managers.bugReport.send": "Bug-Reports über das Mesh senden",
+ "managers.bugReport.startCollector": "Einen mcx-bugs-v1-Collector starten",
+ "managers.bugReport.stopCollector": "Den Bug-Report-Collector stoppen",
+ "managers.bugReport.announce": "Den Bug-Report-Collector announcen"
},
"install_dialog": {
"title": "Plugin installieren",
@@ -780,7 +817,11 @@
"loaded": "Geladene Sideband-Plugins",
"saved": "Sideband-Plugin-Einstellungen gespeichert",
"reloaded": "Sideband-Plugins neu geladen",
- "danger_confirm": "Sideband-Plugins werden als vollständiger Python-Code innerhalb von MeshChatX mit Dateisystem-, Netzwerk- und LXMF-Zugriff ausgeführt. Aktivieren Sie dies nur, wenn Sie jedem Skript im Verzeichnis vertrauen."
+ "danger_confirm": "Sideband-Plugins werden als vollständiger Python-Code innerhalb von MeshChatX mit Dateisystem-, Netzwerk- und LXMF-Zugriff ausgeführt. Aktivieren Sie dies nur, wenn Sie jedem Skript im Verzeichnis vertrauen.",
+ "browse": "Durchsuchen",
+ "browse_title": "Sideband-Plugin-Ordner wählen",
+ "path_prompt": "Vollständigen Pfad zum Sideband-Plugin-Ordner eingeben",
+ "path_picked": "Plugin-Ordner ausgewählt"
}
},
"selftest": {
@@ -1033,7 +1074,7 @@
"tampering_detected": "Änderungen erkannt",
"technical_issues": "Technische Probleme:",
"no_integrity_violations": "Keine unerwarteten Änderungen an überwachten Dateien seit dem letzten Start.",
- "dependency_chain": "Abhängigkeitskette",
+ "dependency_chain": "Stack-Versionen",
"other_core_components": "Andere Kernkomponenten",
"backend_dependencies": "Backend-Abhängigkeiten",
"integrity_backend_error": "Die Backend-Binärdatei der Anwendung (aus ASAR entpackt) scheint sich seit dem letzten Snapshot geändert zu haben. Wenn Sie sie nicht aktualisiert oder geändert haben, prüfen Sie die Änderung.",
@@ -1065,7 +1106,7 @@
"automatic_backups_title": "Automatische Backups",
"backup_download_failed": "Backup konnte nicht heruntergeladen werden",
"backup_downloaded": "Backup heruntergeladen",
- "backend_stack": "Backend-Stack",
+ "backend_stack": "Python-Pakete",
"chrome_runtime": "Chrome",
"contact_alternate": "Alternativadresse",
"contact_details": "Details",
@@ -1081,7 +1122,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} in die Zwischenablage kopiert",
- "core_runtime": "Kernlaufzeit",
+ "core_runtime": "Laufzeitversionen",
"creating": "Wird erstellt…",
"database_backups_desc": "Vollständige Sicherungen Ihrer Kommunikationsdatenbank.",
"database_backups_title": "Datenbank-Backups",
@@ -1107,7 +1148,7 @@
"local_snapshots_desc": "Wiederherstellungspunkte zu einem Zeitpunkt auf der Festplatte anlegen.",
"local_snapshots_title": "Lokale Schnappschüsse",
"lxmf_address": "LXMF-Adresse",
- "lxst_engine": "LXST-Engine",
+ "lxst_engine": "LXST",
"main_instance_badge": "Hauptinstanz",
"nodejs_runtime": "Node.js",
"page_count_label": "Seitenanzahl",
@@ -1385,6 +1426,24 @@
"vector_import_empty": "Keine Objekte in der Datei gefunden.",
"vector_import_failed": "Vektordatei konnte nicht gelesen werden.",
"vector_export_ok": "Export gestartet.",
+ "remote_overlays_title": "Remote-Overlays",
+ "remote_overlays_reload": "Neu laden",
+ "remote_overlays_kind": "Quelltyp",
+ "remote_overlays_url": "Quellen-URL",
+ "remote_overlays_paths": "Repo-Dateipfade (eine pro Zeile)",
+ "remote_overlays_ref": "Git-Ref (Branch, Tag oder Commit)",
+ "remote_overlays_refresh_interval": "Autorefresh-Intervall (Sekunden, 0 = aus)",
+ "remote_overlays_import": "Importieren / abrufen",
+ "remote_overlays_importing": "Wird abgerufen…",
+ "remote_overlays_empty": "Noch keine Remote-Overlays.",
+ "remote_overlays_visible": "Anzeigen",
+ "remote_overlays_refresh": "Aktualisieren",
+ "remote_overlays_copy_drawings": "In Zeichnungen kopieren",
+ "remote_overlays_delete": "Löschen",
+ "remote_overlays_error": "Fehler bei Remote-Overlay",
+ "remote_overlays_export_ok": "Overlay-Export gestartet.",
+ "remote_overlays_export_failed": "Overlay-Export fehlgeschlagen.",
+ "remote_overlays_copied": "Overlay in Zeichnungen kopiert.",
"drop_geo_files": "Kartendatei hier ablegen",
"drop_map_files_hint": "GeoJSON, KML, KMZ oder MBTiles",
"drop_no_geo_files": "Keine GeoJSON-, KML- oder KMZ-Dateien erkannt.",
@@ -1506,6 +1565,8 @@
"share_contact": "Kontakt teilen",
"share_contact_modal_title": "Kontakt teilen",
"share_contact_search_placeholder": "Kontakte durchsuchen…",
+ "share_apk": "App teilen (APK)",
+ "share_apk_failed": "APK konnte nicht geteilt werden.",
"opportunistic_deferred_label": "Wartend",
"opportunistic_deferred_tooltip": "Die Nachricht wird gesendet, sobald der Nutzer online ist oder eine Ankündigung sendet.",
"failed_waiting_announce": "Fehlgeschlagen, warte auf Ankündigung",
@@ -1685,7 +1746,9 @@
"conversation_file_other": "{name} hat eine Datei gesendet",
"conversation_files_you": "Du hast {count} Dateien gesendet",
"conversation_files_other": "{name} hat {count} Dateien gesendet",
- "message_not_found_in_cache": "Nachricht nicht im Cache gefunden"
+ "message_not_found_in_cache": "Nachricht nicht im Cache gefunden",
+ "failed_to_send": "Nachricht konnte nicht gesendet werden",
+ "failed_to_send_image": "Bild {index} konnte nicht gesendet werden: {detail}"
},
"nomadnet": {
"remove_favourite": "Favorit entfernen",
@@ -1813,7 +1876,8 @@
"tab_switch_failed": "Wechsel zu diesem Tab nicht möglich",
"tab_content_mismatch": "Dieser Tab war nicht synchron und wird neu geladen",
"tab_restore_failed": "Seite dieses Tabs konnte nicht wiederhergestellt werden",
- "open_node_failed": "NomadNet-Knoten konnte nicht geöffnet werden"
+ "open_node_failed": "NomadNet-Knoten konnte nicht geöffnet werden",
+ "hide_source": "Quelltext ausblenden"
},
"forwarder": {
"title": "LXMF-Weiterleiter",
@@ -3076,7 +3140,9 @@
"plugins": "Plugins",
"plugins_desc": "MeshChatX-Plugins installieren und verwalten"
},
- "failed_update_reticulum_instance": "Fehler beim Aktualisieren der Reticulum-Instanzeinstellungen!"
+ "failed_update_reticulum_instance": "Fehler beim Aktualisieren der Reticulum-Instanzeinstellungen!",
+ "keyboard_shortcuts_title": "Tastaturkürzel",
+ "keyboard_shortcuts_description": "Schnelle Tastaturaktionen anpassen. Auf Handys standardmäßig eingeklappt."
},
"debug": {
"title": "Debug-Protokolle",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index dab06fae..6b37b76c 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -399,6 +399,11 @@
"backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
"restart_backend": "Restart backend",
+ "network_degraded": "Mesh network unavailable. The app is still running so you can fix interfaces without wiping data.",
+ "recover_network": "Retry network",
+ "open_interfaces": "Open interfaces",
+ "network_recovered": "Network stack recovered",
+ "network_recover_failed": "Could not recover the network stack. Check interfaces and try again.",
"restart_backend_started": "Restarting backend…",
"restart_backend_failed": "Could not restart backend",
"view_backend_logs": "View crash log",
@@ -456,7 +461,30 @@
"telemetry_trust_failed": "Failed to update telemetry trust.",
"telemetry_trust_revoke": "Revoke Telemetry Trust",
"telemetry_trust_grant": "Trust for Telemetry",
- "location_manage_desc": "Manage how your location is shared."
+ "location_manage_desc": "Manage how your location is shared.",
+ "map_settings_title": "Map",
+ "map_settings_desc": "Map defaults, tiles, offline mode, and remote overlay limits.",
+ "map_defaults_heading": "Map defaults",
+ "map_tiles_heading": "Tiles and offline",
+ "map_overlay_limits_heading": "Remote overlay limits",
+ "map_overlay_limits_desc": "Guards for NomadNet and RNGit KMZ/KML/GeoJSON imports. Values are clamped to safe ranges.",
+ "map_overlay_max_bytes": "Max overlay file size (bytes)",
+ "map_overlay_max_features": "Max features per overlay",
+ "map_overlay_max_kmz_uncompressed_bytes": "Max KMZ uncompressed size (bytes)",
+ "map_overlay_max_sources": "Max overlay sources",
+ "map_overlay_max_concurrent_jobs": "Max concurrent overlay jobs",
+ "map_overlay_path_timeout_seconds": "Path lookup timeout (seconds)",
+ "map_overlay_transfer_timeout_seconds": "Transfer timeout (seconds)",
+ "map_overlay_job_timeout_seconds": "Job timeout (seconds)",
+ "map_overlay_max_retries": "Max fetch retries",
+ "map_overlay_retry_delay_seconds": "Retry base delay (seconds)",
+ "map_default_lat": "Default latitude",
+ "map_default_lon": "Default longitude",
+ "map_default_zoom": "Default zoom",
+ "map_tile_server_url": "Tile server URL",
+ "map_nominatim_api_url": "Nominatim API URL",
+ "map_offline_enabled": "Offline MBTiles enabled",
+ "map_tile_cache_enabled": "Tile cache enabled"
},
"common": {
"open": "Open",
@@ -740,6 +768,15 @@
"hooks.announce.received": "Receive mesh announce events",
"hooks.rns.link.event": "Receive RNS link packet and close events",
"managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.debugLog.read": "Read application debug logs",
+ "managers.bugReport.status": "Read bug report collector status",
+ "managers.bugReport.listCollectors": "List heard mcx-bugs-v1 collectors",
+ "managers.bugReport.listReports": "List received bug reports",
+ "managers.bugReport.preview": "Preview redacted debug logs for bug reports",
+ "managers.bugReport.send": "Send bug reports over the mesh",
+ "managers.bugReport.startCollector": "Start an mcx-bugs-v1 collector",
+ "managers.bugReport.stopCollector": "Stop the bug report collector",
+ "managers.bugReport.announce": "Announce the bug report collector",
"managers.rnsLink.open": "Open RNS links to destinations",
"managers.rnsLink.identify": "Identify on RNS links",
"managers.rnsLink.request": "Send request/response over RNS links",
@@ -775,6 +812,10 @@
"master_enable": "Enable Sideband plugin loader (dangerous)",
"command_enable": "Enable Sideband command plugins",
"path": "Plugin directory path",
+ "browse": "Browse",
+ "browse_title": "Choose Sideband plugins folder",
+ "path_prompt": "Enter the full path to the Sideband plugins folder",
+ "path_picked": "Plugin folder selected",
"save": "Save Sideband settings",
"reload": "Reload Sideband plugins",
"loaded": "Loaded Sideband plugins",
@@ -892,6 +933,7 @@
"new_identity": "New Identity",
"import": "Import",
"import_hint": "Restore from a backup file or pasted backup text.",
+ "import_key_only_hint": "This restores the identity key only. Message history and settings require a database backup zip from About.",
"export_all": "Backup all",
"export_all_success": "All identities backed up",
"export_all_failed": "Failed to back up identities",
@@ -905,6 +947,7 @@
"no_identities": "No identities yet",
"create_first": "Create an identity to start chatting on the mesh.",
"switch_confirm": "Switch to \"{name}\"?",
+ "switch_after_restore_confirm": "Switch to restored identity \"{name}\" now?",
"delete_confirm": "Delete \"{name}\"? This cannot be undone.",
"switched": "Identity switched successfully.",
"created": "Identity created successfully",
@@ -929,6 +972,8 @@
"identity_copy_failed": "Failed to copy backup",
"identity_restored": "Identity restored.",
"identity_restore_failed": "Identity restore failed",
+ "identity_restore_empty_file": "Identity file is empty.",
+ "identity_restore_file_too_large": "Identity file is too large.",
"no_identity_available": "No identity available",
"message_count": "{count} messages"
},
@@ -984,13 +1029,13 @@
"integrity_data_error": "Your identity or database files appear to have changed while the app was closed.",
"integrity_warning_footer": "This is advisory, not a confirmed compromise. If you updated the app or edited these files yourself, you can acknowledge to reset the baseline.",
"no_integrity_violations": "No unexpected changes to monitored files since last startup.",
- "dependency_chain": "Dependency Chain",
+ "dependency_chain": "Stack versions",
"app_name": "MeshChatX",
"automatic_backups_desc": "Automated daily snapshots of your database.",
"automatic_backups_title": "Automatic Backups",
"backup_download_failed": "Failed to download backup",
"backup_downloaded": "Backup downloaded",
- "backend_stack": "Backend Stack",
+ "backend_stack": "Python packages",
"chrome_runtime": "Chrome",
"contact_alternate": "Alternate",
"contact_details": "Details",
@@ -1006,14 +1051,14 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} copied to clipboard",
- "core_runtime": "Core Runtime",
+ "core_runtime": "Runtime versions",
"creating": "Creating...",
"database_backups_desc": "Full snapshots of your communications database.",
"database_backups_title": "Database Backups",
"database_health_maintenance": "Database Health & Maintenance",
- "dep_lxmf_subtitle": "Lightweight Extensible Message Format",
- "dep_lxmfy_subtitle": "LXMF Bot framework",
- "dep_rns_subtitle": "Reticulum Network Stack",
+ "dep_lxmf_subtitle": "LXMF",
+ "dep_lxmfy_subtitle": "LXMFy",
+ "dep_rns_subtitle": "RNS",
"download_backup": "Download Backup",
"downloading": "Downloading...",
"electron_runtime": "Electron",
@@ -1032,7 +1077,7 @@
"local_snapshots_desc": "Create point-in-time restore points on disk.",
"local_snapshots_title": "Local Snapshots",
"lxmf_address": "LXMF Address",
- "lxst_engine": "LXST Engine",
+ "lxst_engine": "LXST",
"main_instance_badge": "Main Instance",
"nodejs_runtime": "Node.js",
"page_count_label": "Page Count",
@@ -1333,6 +1378,24 @@
"vector_import_empty": "No features found in file.",
"vector_import_failed": "Could not read vector file.",
"vector_export_ok": "Export started.",
+ "remote_overlays_title": "Remote overlays",
+ "remote_overlays_reload": "Reload",
+ "remote_overlays_kind": "Source type",
+ "remote_overlays_url": "Source URL",
+ "remote_overlays_paths": "Repo file paths (one per line)",
+ "remote_overlays_ref": "Git ref (branch, tag, or commit)",
+ "remote_overlays_refresh_interval": "Autorefresh interval (seconds, 0 = off)",
+ "remote_overlays_import": "Import / fetch",
+ "remote_overlays_importing": "Fetching…",
+ "remote_overlays_empty": "No remote overlays yet.",
+ "remote_overlays_visible": "Show",
+ "remote_overlays_refresh": "Refresh",
+ "remote_overlays_copy_drawings": "Copy to drawings",
+ "remote_overlays_delete": "Delete",
+ "remote_overlays_error": "Remote overlay error",
+ "remote_overlays_export_ok": "Overlay export started.",
+ "remote_overlays_export_failed": "Overlay export failed.",
+ "remote_overlays_copied": "Copied overlay into drawings.",
"drop_geo_files": "Drop map file here",
"drop_map_files_hint": "GeoJSON, KML, KMZ, or MBTiles",
"drop_no_geo_files": "No GeoJSON, KML, or KMZ files detected.",
@@ -1413,6 +1476,8 @@
"share_contact": "Share contact",
"share_contact_modal_title": "Share contact",
"share_contact_search_placeholder": "Search contacts…",
+ "share_apk": "Share app (APK)",
+ "share_apk_failed": "Could not open the share sheet for the APK.",
"custom_display_name": "Custom Display Name",
"stranger_banner_text": "This peer is not in your contacts. Attachments from strangers are blocked.",
"add_to_contacts": "Add to Contacts",
@@ -1533,6 +1598,8 @@
"failed_add_contact": "Failed to add contact",
"ingesting_paper_message": "Ingesting paper message...",
"failed_ingest_paper": "Failed to ingest paper message",
+ "failed_to_send": "Failed to send message",
+ "failed_to_send_image": "Failed to send image {index}: {detail}",
"enter_display_name": "Enter a custom display name",
"failed_update_display_name": "Failed to update display name",
"failed_load_audio": "Failed to load audio attachment.",
@@ -1654,6 +1721,8 @@
},
"shortcut_saved": "Shortcut saved",
"shortcut_deleted": "Shortcut deleted",
+ "keyboard_shortcuts_title": "Keyboard Shortcuts",
+ "keyboard_shortcuts_description": "Customize quick keyboard actions. Collapsed by default on phones.",
"archived_pages_flushed": "Archived pages flushed.",
"failed_enable_transport": "Failed to enable transport mode!",
"failed_disable_transport": "Failed to disable transport mode!",
@@ -1807,6 +1876,7 @@
"add_favourite": "Add Favourite",
"identify": "Identify",
"pop_out_browser": "Pop out browser",
+ "hide_source": "Hide source",
"new_tab": "New tab",
"new_tab_shortcut": "New tab (Ctrl+T)",
"page_archives": "Page Archives",
@@ -1947,7 +2017,11 @@
"disabled": "Disabled",
"forwarding_to": "Forwarding to: {hash}",
"source_filter_display": "Source filter: {hash}",
- "delete_confirm": "Are you sure you want to delete this rule?"
+ "delete_confirm": "Are you sure you want to delete this rule?",
+ "invalid_hash": "Forward-to hash must be a 32-character hex value",
+ "send_failed": "Could not send forwarding update (websocket disconnected)",
+ "rule_added": "Forwarding rule added",
+ "rule_deleted": "Forwarding rule deleted"
},
"archives": {
"description": "Search Nomad Network pages stored in your local archive.",
@@ -2074,6 +2148,10 @@
"failed_drop": "Could not drop path",
"error_drop": "Error dropping path",
"failed_request": "Failed to request path",
+ "path_requested": "Path requested for {hash}...",
+ "drop_confirm": "Are you sure you want to drop the path to {hash}?",
+ "drop_via_confirm": "Drop ALL paths via {hash}?",
+ "invalid_hops": "Hops filter must be a number",
"paths_dropped": "Paths dropped",
"failed_drop_paths": "Failed to drop paths",
"purge_confirm": "Purge all announce queues? This cannot be undone.",
@@ -2105,7 +2183,11 @@
},
"propagation_nodes": {
"title": "Propagation nodes",
- "description": "Pick preferred mesh propagation nodes, watch live stats, and run quick path checks."
+ "description": "Pick preferred mesh propagation nodes, watch live stats, and run quick path checks.",
+ "load_failed": "Failed to load propagation nodes",
+ "local_restarted": "Local propagation node restarted",
+ "local_stopped": "Local propagation node stopped",
+ "local_started": "Local propagation node started"
},
"sieve_filters": {
"title": "Sieve filters",
@@ -2901,7 +2983,10 @@
"incoming_announces": "Incoming Announces",
"outgoing_announces": "Outgoing Announces",
"airtime": "Airtime",
- "channel_load": "Channel Load"
+ "channel_load": "Channel Load",
+ "blackhole_label": "Blackhole: {state}",
+ "blackhole_publishing": "Publishing",
+ "blackhole_inactive": "Inactive"
},
"translator": {
"text_translation": "Text Translation",
@@ -3218,6 +3303,13 @@
"identity_base32_placeholder": "Paste base32 private identity key",
"identity_import_required": "Choose an identity file or paste a base32 key to import.",
"identity_import_failed": "Failed to import identity",
+ "identity_import_empty_file": "Identity file is empty.",
+ "identity_import_file_too_large": "Identity file is too large.",
+ "identity_import_key_only_hint": "This imports your identity key only. To restore messages and settings, use About → Restore from File with a database backup zip.",
+ "identity_file_overrides_base32": "A selected file will be used instead of the pasted base32 key.",
+ "identity_import_pending_activate": "You imported an identity but have not activated it yet. Activate it now?",
+ "identity_import_pending_kept": "Imported identity was saved but not activated. Switch to it later from Identities.",
+ "identity_default_delete_failed": "Imported identity is active, but the default identity could not be removed.",
"identity_name_update_failed": "Failed to set display name",
"identity_switch_failed": "Failed to activate imported identity",
"suggested_networks": "Suggested Public Networks",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index fc085184..99a4434f 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "Revocar confianza de telemetría",
"telemetry_trust_grant": "Confianza para telemetría",
"location_manage_desc": "Administrar cómo se comparte su ubicación.",
+ "map_settings_title": "Mapa",
+ "map_settings_desc": "Valores predeterminados del mapa, teselas, modo sin conexión y límites de superposiciones remotas.",
+ "map_defaults_heading": "Valores predeterminados del mapa",
+ "map_tiles_heading": "Teselas y sin conexión",
+ "map_overlay_limits_heading": "Límites de superposiciones remotas",
+ "map_overlay_limits_desc": "Límites de protección para importaciones KMZ/KML/GeoJSON desde NomadNet y RNGit. Los valores se restringen a rangos seguros.",
+ "map_overlay_max_bytes": "Tamaño máx. del archivo de superposición (bytes)",
+ "map_overlay_max_features": "Elementos máx. por superposición",
+ "map_overlay_max_kmz_uncompressed_bytes": "Tamaño máx. KMZ sin comprimir (bytes)",
+ "map_overlay_max_sources": "Fuentes de superposición máx.",
+ "map_overlay_max_concurrent_jobs": "Trabajos de superposición concurrentes máx.",
+ "map_overlay_path_timeout_seconds": "Tiempo de espera de búsqueda de ruta (segundos)",
+ "map_overlay_transfer_timeout_seconds": "Tiempo de espera de transferencia (segundos)",
+ "map_overlay_job_timeout_seconds": "Tiempo de espera del trabajo (segundos)",
+ "map_overlay_max_retries": "Reintentos de obtención máx.",
+ "map_overlay_retry_delay_seconds": "Retraso base de reintento (segundos)",
+ "map_default_lat": "Latitud predeterminada",
+ "map_default_lon": "Longitud predeterminada",
+ "map_default_zoom": "Zoom predeterminado",
+ "map_tile_server_url": "URL del servidor de teselas",
+ "map_nominatim_api_url": "URL de la API de Nominatim",
+ "map_offline_enabled": "MBTiles sin conexión activados",
+ "map_tile_cache_enabled": "Caché de teselas activada",
"restart_rns": "Reiniciar RNS",
"flood_protection": "Protección contra inundaciones",
"flood_protection_description": "Aumenta automáticamente el costo del sello entrante cuando se reciben demasiados mensajes por minuto desde muchas fuentes. Esto hace que los ataques de spam coordinados sean computacionalmente costosos mientras mantiene las conversaciones normales asequibles.",
@@ -456,7 +479,12 @@
"rpc_key_show": "Mostrar clave RPC",
"rpc_key_hide": "Ocultar clave RPC",
"refresh_community_interfaces": "Actualizar desde directory.rns.recipes",
- "refresh_community_interfaces_busy": "Actualizando…"
+ "refresh_community_interfaces_busy": "Actualizando…",
+ "network_degraded": "Red mesh no disponible. La aplicación sigue en ejecución para que pueda reparar interfaces sin borrar datos.",
+ "recover_network": "Reintentar red",
+ "open_interfaces": "Abrir interfaces",
+ "network_recovered": "Pila de red recuperada",
+ "network_recover_failed": "No se pudo recuperar la pila de red. Revise las interfaces e inténtelo de nuevo."
},
"common": {
"open": "Abierto",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Enviar paquetes a través de enlaces RNS",
"managers.rnsLink.close": "Cerrar enlaces RNS",
"storage.isolated": "Usar almacenamiento aislado del complemento",
- "network.fetch": "Realizar solicitudes HTTP salientes a Internet"
+ "network.fetch": "Realizar solicitudes HTTP salientes a Internet",
+ "managers.debugLog.read": "Leer registros de depuración de la aplicación",
+ "managers.bugReport.status": "Leer el estado del recolector de informes de errores",
+ "managers.bugReport.listCollectors": "Listar recolectores mcx-bugs-v1 escuchados",
+ "managers.bugReport.listReports": "Listar informes de errores recibidos",
+ "managers.bugReport.preview": "Vista previa de registros depurados para informes",
+ "managers.bugReport.send": "Enviar informes de errores por la malla",
+ "managers.bugReport.startCollector": "Iniciar un recolector mcx-bugs-v1",
+ "managers.bugReport.stopCollector": "Detener el recolector de informes de errores",
+ "managers.bugReport.announce": "Anunciar el recolector de informes de errores"
},
"install_dialog": {
"title": "Instalar complemento",
@@ -780,7 +817,11 @@
"loaded": "Plugins de Sideband cargados",
"saved": "Configuración de plugins de Sideband guardada",
"reloaded": "Plugins de Sideband recargados",
- "danger_confirm": "Los plugins de Sideband se ejecutan como código Python completo dentro de MeshChatX con acceso al sistema de archivos, red y LXMF. Habilítalos solo si confías en cada script del directorio."
+ "danger_confirm": "Los plugins de Sideband se ejecutan como código Python completo dentro de MeshChatX con acceso al sistema de archivos, red y LXMF. Habilítalos solo si confías en cada script del directorio.",
+ "browse": "Examinar",
+ "browse_title": "Elegir carpeta de plugins Sideband",
+ "path_prompt": "Introduce la ruta completa a la carpeta de plugins Sideband",
+ "path_picked": "Carpeta de plugins seleccionada"
}
},
"selftest": {
@@ -984,7 +1025,7 @@
"integrity_data_error": "Sus archivos de identidad o base de datos parecen haber cambiado mientras la aplicación estaba cerrada.",
"integrity_warning_footer": "Esto es informativo, no una confirmación de compromiso. Si actualizó la aplicación o editó estos archivos usted mismo, puede confirmar para restablecer la línea base.",
"no_integrity_violations": "No hay cambios inesperados en los archivos supervisados desde el último inicio.",
- "dependency_chain": "Cadena de dependencia",
+ "dependency_chain": "Versiones del stack",
"other_core_components": "Otros componentes básicos",
"backend_dependencies": "Dependencias de backend",
"delete_snapshot_confirm": "¿Seguro que quieres borrar esta instantánea?",
@@ -1013,7 +1054,7 @@
"automatic_backups_title": "Copias automáticas",
"backup_download_failed": "No se pudo descargar la copia de seguridad",
"backup_downloaded": "Copia de seguridad descargada",
- "backend_stack": "Pila del backend",
+ "backend_stack": "Paquetes Python",
"chrome_runtime": "Chrome",
"contact_alternate": "Dirección alternativa",
"contact_details": "Detalles",
@@ -1029,7 +1070,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} copiado al portapapeles",
- "core_runtime": "Motor principal",
+ "core_runtime": "Versiones de runtime",
"creating": "Creando…",
"database_backups_desc": "Copias completas de la base de datos de tus comunicaciones.",
"database_backups_title": "Copias de seguridad de la base de datos",
@@ -1055,7 +1096,7 @@
"local_snapshots_desc": "Crea puntos de restauración en disco en un momento concreto.",
"local_snapshots_title": "Instantáneas locales",
"lxmf_address": "Dirección LXMF",
- "lxst_engine": "Motor LXST",
+ "lxst_engine": "LXST",
"main_instance_badge": "Instancia principal",
"nodejs_runtime": "Node.js",
"page_count_label": "Número de páginas",
@@ -1333,6 +1374,24 @@
"vector_import_empty": "No se encontraron elementos en el archivo.",
"vector_import_failed": "No se pudo leer el archivo vectorial.",
"vector_export_ok": "Exportación iniciada.",
+ "remote_overlays_title": "Superposiciones remotas",
+ "remote_overlays_reload": "Recargar",
+ "remote_overlays_kind": "Tipo de origen",
+ "remote_overlays_url": "URL de origen",
+ "remote_overlays_paths": "Rutas de archivos del repositorio (una por línea)",
+ "remote_overlays_ref": "Ref. de Git (rama, etiqueta o commit)",
+ "remote_overlays_refresh_interval": "Intervalo de actualización automática (segundos, 0 = desactivado)",
+ "remote_overlays_import": "Importar / obtener",
+ "remote_overlays_importing": "Obteniendo…",
+ "remote_overlays_empty": "Aún no hay superposiciones remotas.",
+ "remote_overlays_visible": "Mostrar",
+ "remote_overlays_refresh": "Actualizar",
+ "remote_overlays_copy_drawings": "Copiar a dibujos",
+ "remote_overlays_delete": "Eliminar",
+ "remote_overlays_error": "Error de superposición remota",
+ "remote_overlays_export_ok": "Exportación de superposición iniciada.",
+ "remote_overlays_export_failed": "Error al exportar la superposición.",
+ "remote_overlays_copied": "Superposición copiada a dibujos.",
"drop_geo_files": "Suelta el archivo del mapa aquí",
"drop_map_files_hint": "GeoJSON, KML, KMZ o MBTiles",
"drop_no_geo_files": "No se detectaron archivos GeoJSON, KML o KMZ.",
@@ -1413,6 +1472,8 @@
"share_contact": "Comparta contacto",
"share_contact_modal_title": "Compartir contacto",
"share_contact_search_placeholder": "Buscar contactos…",
+ "share_apk": "Compartir app (APK)",
+ "share_apk_failed": "No se pudo compartir el APK.",
"custom_display_name": "Nombre de la pantalla personalizada",
"stranger_banner_text": "Este pare no está en tus contactos. Los adjuntos de extraños están bloqueados.",
"add_to_contacts": "Añadir a Contactos",
@@ -1633,7 +1694,9 @@
"conversation_file_other": "{name} envió un archivo",
"conversation_files_you": "Enviaste {count} archivos",
"conversation_files_other": "{name} envió {count} archivos",
- "message_not_found_in_cache": "Mensaje no encontrado en caché"
+ "message_not_found_in_cache": "Mensaje no encontrado en caché",
+ "failed_to_send": "Error al enviar el mensaje",
+ "failed_to_send_image": "Error al enviar la imagen {index}: {detail}"
},
"settings": {
"shortcut_saved": "Guardado a mano",
@@ -1698,7 +1761,9 @@
"plugins": "Complementos",
"plugins_desc": "Instalar y gestionar complementos de MeshChatX"
},
- "failed_update_reticulum_instance": "¡Error al actualizar la configuración de la instancia de Reticulum!"
+ "failed_update_reticulum_instance": "¡Error al actualizar la configuración de la instancia de Reticulum!",
+ "keyboard_shortcuts_title": "Atajos de teclado",
+ "keyboard_shortcuts_description": "Personaliza acciones rápidas de teclado. Contraído por defecto en móviles."
},
"debug": {
"title": "Registros de depuración",
@@ -1928,7 +1993,8 @@
"tab_switch_failed": "No se pudo cambiar a esa pestaña",
"tab_content_mismatch": "Esta pestaña estaba desincronizada y se está recargando",
"tab_restore_failed": "No se pudo restaurar la página de esta pestaña",
- "open_node_failed": "No se pudo abrir el nodo NomadNet"
+ "open_node_failed": "No se pudo abrir el nodo NomadNet",
+ "hide_source": "Ocultar código"
},
"forwarder": {
"title": "Reenviador LXMF",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index bce38596..2fa904a4 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -424,6 +424,29 @@
"telemetry_trust_revoke": "Poista käyttötietojen luottamus",
"telemetry_trust_grant": "Luota käyttötietojen välitystä varten",
"location_manage_desc": "Hallinnoi sijainnin jakamisen asetuksia.",
+ "map_settings_title": "Kartta",
+ "map_settings_desc": "Kartan oletukset, tiilet, offline-tila ja etäpeittokuvien rajat.",
+ "map_defaults_heading": "Kartan oletukset",
+ "map_tiles_heading": "Tiilet ja offline",
+ "map_overlay_limits_heading": "Etäpeittokuvien rajat",
+ "map_overlay_limits_desc": "Suojarajat NomadNet- ja RNGit-KMZ/KML/GeoJSON-tuonneille. Arvot rajataan turvallisiin väleihin.",
+ "map_overlay_max_bytes": "Peittokuvatiedoston enimmäiskoko (tavua)",
+ "map_overlay_max_features": "Kohteiden enimmäismäärä peittokuvaa kohti",
+ "map_overlay_max_kmz_uncompressed_bytes": "KMZ:n enimmäiskoko pakkaamattomana (tavua)",
+ "map_overlay_max_sources": "Peittokuvan lähteiden enimmäismäärä",
+ "map_overlay_max_concurrent_jobs": "Samanaikaisten peittokuvatehtävien enimmäismäärä",
+ "map_overlay_path_timeout_seconds": "Polun haun aikakatkaisu (sekuntia)",
+ "map_overlay_transfer_timeout_seconds": "Siirron aikakatkaisu (sekuntia)",
+ "map_overlay_job_timeout_seconds": "Tehtävän aikakatkaisu (sekuntia)",
+ "map_overlay_max_retries": "Noudon uudelleenyritysten enimmäismäärä",
+ "map_overlay_retry_delay_seconds": "Uudelleenyrityksen perusviive (sekuntia)",
+ "map_default_lat": "Oletusleveysaste",
+ "map_default_lon": "Oletuspituusaste",
+ "map_default_zoom": "Oletuszoomaus",
+ "map_tile_server_url": "Tiilipalvelimen URL",
+ "map_nominatim_api_url": "Nominatim-API:n URL",
+ "map_offline_enabled": "Offline-MBTiles käytössä",
+ "map_tile_cache_enabled": "Tiilivälimuisti käytössä",
"desktop_tray_enabled": "Järjestelmälokeron integraatio",
"desktop_tray_enabled_description": "Pidä kuvake lokerossa, jotta MeshChatX voi piiloutua taustalle sulkemisen sijaan.",
"desktop_close_behavior": "Kun ikkuna suljetaan",
@@ -456,7 +479,12 @@
"rpc_key_show": "Näytä RPC-avain",
"rpc_key_hide": "Piilota RPC-avain",
"refresh_community_interfaces": "Päivitä lähteestä directory.rns.recipes",
- "refresh_community_interfaces_busy": "Päivitetään…"
+ "refresh_community_interfaces_busy": "Päivitetään…",
+ "network_degraded": "Mesh-verkko ei ole käytettävissä. Sovellus pysyy käynnissä, jotta voit korjata liittymiä ilman tietojen tyhjennystä.",
+ "recover_network": "Yritä verkkoa uudelleen",
+ "open_interfaces": "Avaa liittymät",
+ "network_recovered": "Verkkopinon palautus onnistui",
+ "network_recover_failed": "Verkkopinoa ei voitu palauttaa. Tarkista liittymät ja yritä uudelleen."
},
"common": {
"open": "Avaa",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Lähetä paketteja RNS-linkkien yli",
"managers.rnsLink.close": "Sulje RNS-linkkejä",
"storage.isolated": "Käytä eristettyä liitännäistallennustilaa",
- "network.fetch": "Tee ulospäin suuntautuvia internet-HTTP-pyyntöjä"
+ "network.fetch": "Tee ulospäin suuntautuvia internet-HTTP-pyyntöjä",
+ "managers.debugLog.read": "Lue sovelluksen vianetsintälokit",
+ "managers.bugReport.status": "Lue bugiraporttikeräimen tila",
+ "managers.bugReport.listCollectors": "Listaa kuullut mcx-bugs-v1-keräimet",
+ "managers.bugReport.listReports": "Listaa vastaanotetut bugiraportit",
+ "managers.bugReport.preview": "Esikatsele redaktoituja vianetsintälokeja",
+ "managers.bugReport.send": "Lähetä bugiraportteja verkon yli",
+ "managers.bugReport.startCollector": "Käynnistä mcx-bugs-v1-keräin",
+ "managers.bugReport.stopCollector": "Pysäytä bugiraporttikeräin",
+ "managers.bugReport.announce": "Ilmoita bugiraporttikeräin"
},
"install_dialog": {
"title": "Asenna liitännäinen",
@@ -780,7 +817,11 @@
"loaded": "Ladatut Sideband-liitännäiset",
"saved": "Sideband-liitännäisten asetukset tallennettu",
"reloaded": "Sideband-liitännäiset ladattu uudelleen",
- "danger_confirm": "Sideband-liitännäiset toimivat täysinäisenä Python-koodina MeshChatX:ssä, jolla on pääsy tiedostojärjestelmään, verkkoon ja LXMF:ään. Ota käyttöön vain, jos luotat jokaiseen hakemiston skriptiin."
+ "danger_confirm": "Sideband-liitännäiset toimivat täysinäisenä Python-koodina MeshChatX:ssä, jolla on pääsy tiedostojärjestelmään, verkkoon ja LXMF:ään. Ota käyttöön vain, jos luotat jokaiseen hakemiston skriptiin.",
+ "browse": "Selaa",
+ "browse_title": "Valitse Sideband-liitännäiskansio",
+ "path_prompt": "Anna Sideband-liitännäiskansion täysi polku",
+ "path_picked": "Liitännäiskansio valittu"
}
},
"selftest": {
@@ -984,13 +1025,13 @@
"integrity_data_error": "Identiteettejäsi tai tietokantaasi on muutettu sovelluksen ulkopuolella.",
"integrity_warning_footer": "Jatka varoen. Jos et itse muokannut tai päivittänyt näitä tiedostoja, asennuksesi voi olla vaarantunut.",
"no_integrity_violations": "Järjestelmä on eheä viime käynnistyksen jäljiltä.",
- "dependency_chain": "Riippuvuusketju",
+ "dependency_chain": "Pinon versiot",
"app_name": "MeshChatX",
"automatic_backups_desc": "Automated daily snapshots of your databas Tietokannan automaattiset päivittäiset tilannekuvat.",
"automatic_backups_title": "Automaattiset varmuuskopiot",
"backup_download_failed": "Varmuuskopion lataaminen epäonnistui",
"backup_downloaded": "Varmuuskopio ladattu",
- "backend_stack": "Takaosan pino",
+ "backend_stack": "Python-paketit",
"chrome_runtime": "Chrome",
"contact_alternate": "Vaihtoehto",
"contact_details": "Yksityiskohdat",
@@ -1006,7 +1047,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Osta kahvit",
"copied_label_to_clipboard": "{label} kopioitu leikepöydälle",
- "core_runtime": "Ytimen runtime",
+ "core_runtime": "Ajonaikaiset versiot",
"creating": "Luo...",
"database_backups_desc": "Kokonainen tilannekuva viestintätietokannastasi.",
"database_backups_title": "Tietokannan varmuuskopio",
@@ -1032,7 +1073,7 @@
"local_snapshots_desc": "Luo levylle tilannekuva hetkestä.",
"local_snapshots_title": "Paikalliset tilannekuvat",
"lxmf_address": "LXMF-kohde",
- "lxst_engine": "LXST-moottori",
+ "lxst_engine": "LXST",
"main_instance_badge": "Pääinstanssi",
"nodejs_runtime": "Node.js",
"page_count_label": "Sivujen lukumäärä",
@@ -1333,6 +1374,24 @@
"vector_import_empty": "Tiedostosta ei löytynyt karttamerkkejä.",
"vector_import_failed": "Vektoritiedoston lukeminen epäonnistui.",
"vector_export_ok": "Vienti aloitettu.",
+ "remote_overlays_title": "Etäpeittokuvat",
+ "remote_overlays_reload": "Lataa uudelleen",
+ "remote_overlays_kind": "Lähteen tyyppi",
+ "remote_overlays_url": "Lähteen URL",
+ "remote_overlays_paths": "Repotiedostopolut (yksi per rivi)",
+ "remote_overlays_ref": "Git-viite (haara, tagi tai commit)",
+ "remote_overlays_refresh_interval": "Automaattisen päivityksen väli (sekuntia, 0 = pois)",
+ "remote_overlays_import": "Tuo / nouda",
+ "remote_overlays_importing": "Noudetaan…",
+ "remote_overlays_empty": "Ei vielä etäpeittokuvia.",
+ "remote_overlays_visible": "Näytä",
+ "remote_overlays_refresh": "Päivitä",
+ "remote_overlays_copy_drawings": "Kopioi piirroksiin",
+ "remote_overlays_delete": "Poista",
+ "remote_overlays_error": "Etäpeittokuvan virhe",
+ "remote_overlays_export_ok": "Peittokuvan vienti aloitettu.",
+ "remote_overlays_export_failed": "Peittokuvan vienti epäonnistui.",
+ "remote_overlays_copied": "Peittokuva kopioitu piirroksiin.",
"drop_geo_files": "Pudota karttatiedosto tähän",
"drop_map_files_hint": "GeoJSON, KML, KMZ tai MBTiles",
"drop_no_geo_files": "GeoJSON, KML, tai KMZ -tiedostoa ei havaittu.",
@@ -1413,6 +1472,8 @@
"share_contact": "Jaa yhteystieto",
"share_contact_modal_title": "Jaa yhteystieto",
"share_contact_search_placeholder": "Hae yhteystiedoista…",
+ "share_apk": "Jaa sovellus (APK)",
+ "share_apk_failed": "APK:n jakaminen epäonnistui.",
"custom_display_name": "Mukautettu näyttönimi",
"stranger_banner_text": "Osallistuja ei ole yhteystietolistallasi. Liitteet vierailta estetään.",
"add_to_contacts": "Lisää yhteystietoihin",
@@ -1633,7 +1694,9 @@
"conversation_file_other": "{name} lähetti tiedoston",
"conversation_files_you": "Lähetit {count} tiedostoa",
"conversation_files_other": "{name} lähetti {count} tiedostoa",
- "message_not_found_in_cache": "Viestiä ei löytynyt välimuistista"
+ "message_not_found_in_cache": "Viestiä ei löytynyt välimuistista",
+ "failed_to_send": "Viestin lähettäminen epäonnistui",
+ "failed_to_send_image": "Kuvan {index} lähettäminen epäonnistui: {detail}"
},
"settings": {
"tabs": {
@@ -1698,7 +1761,9 @@
"micron_wasm_update_toast_installed": "Asennettiin Micron WASM {tag}.",
"micron_wasm_update_toast_uploaded": "Asennettiin WASM tiedostosta.",
"micron_wasm_update_toast_reverted": "Palautettiin paketin Micron WASM.",
- "failed_update_reticulum_instance": "Reticulum-esiintymän asetusten päivitys epäonnistui!"
+ "failed_update_reticulum_instance": "Reticulum-esiintymän asetusten päivitys epäonnistui!",
+ "keyboard_shortcuts_title": "Pikanäppäimet",
+ "keyboard_shortcuts_description": "Mukauta pikanäppäimiä. Puhelimissa oletuksena tiivistetty."
},
"debug": {
"title": "Vianetsintälokit",
@@ -1928,7 +1993,8 @@
"tab_switch_failed": "Välilehteen vaihto epäonnistui",
"tab_content_mismatch": "Tämä välilehti oli epäsynkassa ja ladataan uudelleen",
"tab_restore_failed": "Välilehden sivua ei voitu palauttaa",
- "open_node_failed": "NomadNet-solmua ei voitu avata"
+ "open_node_failed": "NomadNet-solmua ei voitu avata",
+ "hide_source": "Piilota lähdekoodi"
},
"forwarder": {
"title": "LXMF-välittäjä",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index b555cf14..234d01a1 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "Revoquer la confiance en télémétrie",
"telemetry_trust_grant": "Confiance pour la télémétrie",
"location_manage_desc": "Gérez comment votre emplacement est partagé.",
+ "map_settings_title": "Carte",
+ "map_settings_desc": "Paramètres par défaut de la carte, tuiles, mode hors ligne et limites des calques distants.",
+ "map_defaults_heading": "Paramètres par défaut de la carte",
+ "map_tiles_heading": "Tuiles et hors ligne",
+ "map_overlay_limits_heading": "Limites des calques distants",
+ "map_overlay_limits_desc": "Gardes-fous pour les imports KMZ/KML/GeoJSON depuis NomadNet et RNGit. Les valeurs sont limitées à des plages sûres.",
+ "map_overlay_max_bytes": "Taille max. du fichier de calque (octets)",
+ "map_overlay_max_features": "Entités max. par calque",
+ "map_overlay_max_kmz_uncompressed_bytes": "Taille max. KMZ non compressé (octets)",
+ "map_overlay_max_sources": "Sources de calques max.",
+ "map_overlay_max_concurrent_jobs": "Tâches de calque simultanées max.",
+ "map_overlay_path_timeout_seconds": "Délai de recherche de chemin (secondes)",
+ "map_overlay_transfer_timeout_seconds": "Délai de transfert (secondes)",
+ "map_overlay_job_timeout_seconds": "Délai de tâche (secondes)",
+ "map_overlay_max_retries": "Tentatives de récupération max.",
+ "map_overlay_retry_delay_seconds": "Délai de base entre tentatives (secondes)",
+ "map_default_lat": "Latitude par défaut",
+ "map_default_lon": "Longitude par défaut",
+ "map_default_zoom": "Zoom par défaut",
+ "map_tile_server_url": "URL du serveur de tuiles",
+ "map_nominatim_api_url": "URL de l’API Nominatim",
+ "map_offline_enabled": "MBTiles hors ligne activés",
+ "map_tile_cache_enabled": "Cache de tuiles activé",
"restart_rns": "Redémarrer RNS",
"flood_protection": "Protection contre les inondations",
"flood_protection_description": "Augmente automatiquement le coût du timbre entrant lors de la réception de trop nombreux messages par minute provenant de nombreuses sources. Cela rend les attaques de spam coordonnées coûteuses en calcul tout en maintenant les conversations normales abordables.",
@@ -456,7 +479,12 @@
"rpc_key_show": "Afficher la clé RPC",
"rpc_key_hide": "Masquer la clé RPC",
"refresh_community_interfaces": "Actualiser depuis directory.rns.recipes",
- "refresh_community_interfaces_busy": "Actualisation…"
+ "refresh_community_interfaces_busy": "Actualisation…",
+ "network_degraded": "Réseau mesh indisponible. L'application reste ouverte pour réparer les interfaces sans effacer les données.",
+ "recover_network": "Réessayer le réseau",
+ "open_interfaces": "Ouvrir les interfaces",
+ "network_recovered": "Pile réseau rétablie",
+ "network_recover_failed": "Impossible de rétablir la pile réseau. Vérifiez les interfaces et réessayez."
},
"common": {
"open": "Ouvrir",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Envoyer des paquets via les liens RNS",
"managers.rnsLink.close": "Fermer les liens RNS",
"storage.isolated": "Utiliser un stockage isolé pour le plugin",
- "network.fetch": "Effectuer des requêtes HTTP sortantes sur Internet"
+ "network.fetch": "Effectuer des requêtes HTTP sortantes sur Internet",
+ "managers.debugLog.read": "Lire les journaux de débogage de l'application",
+ "managers.bugReport.status": "Lire le statut du collecteur de rapports de bogues",
+ "managers.bugReport.listCollectors": "Lister les collecteurs mcx-bugs-v1 entendus",
+ "managers.bugReport.listReports": "Lister les rapports de bogues reçus",
+ "managers.bugReport.preview": "Prévisualiser les journaux expurgés pour les rapports",
+ "managers.bugReport.send": "Envoyer des rapports de bogues sur le mesh",
+ "managers.bugReport.startCollector": "Démarrer un collecteur mcx-bugs-v1",
+ "managers.bugReport.stopCollector": "Arrêter le collecteur de rapports de bogues",
+ "managers.bugReport.announce": "Annoncer le collecteur de rapports de bogues"
},
"install_dialog": {
"title": "Installer le plugin",
@@ -780,7 +817,11 @@
"loaded": "Plugins Sideband chargés",
"saved": "Paramètres des plugins Sideband enregistrés",
"reloaded": "Plugins Sideband rechargés",
- "danger_confirm": "Les plugins Sideband s'exécutent en tant que code Python complet dans MeshChatX avec accès au système de fichiers, au réseau et à LXMF. Activez uniquement si vous faites confiance à chaque script du répertoire."
+ "danger_confirm": "Les plugins Sideband s'exécutent en tant que code Python complet dans MeshChatX avec accès au système de fichiers, au réseau et à LXMF. Activez uniquement si vous faites confiance à chaque script du répertoire.",
+ "browse": "Parcourir",
+ "browse_title": "Choisir le dossier des plugins Sideband",
+ "path_prompt": "Entrez le chemin complet du dossier des plugins Sideband",
+ "path_picked": "Dossier de plugins sélectionné"
}
},
"selftest": {
@@ -984,7 +1025,7 @@
"integrity_data_error": "Vos fichiers d'identité ou de base de données semblent avoir changé pendant que l'application était fermée.",
"integrity_warning_footer": "Ceci est indicatif, pas une compromission confirmée. Si vous avez mis à jour l'application ou modifié ces fichiers vous-même, vous pouvez confirmer pour réinitialiser la référence.",
"no_integrity_violations": "Aucun changement inattendu dans les fichiers surveillés depuis le dernier démarrage.",
- "dependency_chain": "Chaîne de dépendance",
+ "dependency_chain": "Versions de la pile",
"other_core_components": "Autres éléments de base",
"backend_dependencies": "Dépendances du moteur",
"delete_snapshot_confirm": "Voulez-vous vraiment supprimer cet instantané ?",
@@ -1013,7 +1054,7 @@
"automatic_backups_title": "Sauvegardes automatiques",
"backup_download_failed": "Échec du téléchargement de la sauvegarde",
"backup_downloaded": "Sauvegarde téléchargée",
- "backend_stack": "Pile logicielle du backend",
+ "backend_stack": "Paquets Python",
"chrome_runtime": "Chrome",
"contact_alternate": "Adresse alternative",
"contact_details": "Détails",
@@ -1029,7 +1070,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} copié dans le presse-papiers",
- "core_runtime": "Exécution principale",
+ "core_runtime": "Versions d'exécution",
"creating": "Création…",
"database_backups_desc": "Copies complètes de la base de données de vos communications.",
"database_backups_title": "Sauvegardes de la base de données",
@@ -1055,7 +1096,7 @@
"local_snapshots_desc": "Créez des points de restauration sur disque à un instant donné.",
"local_snapshots_title": "Instantanés locaux",
"lxmf_address": "Adresse LXMF",
- "lxst_engine": "Moteur LXST",
+ "lxst_engine": "LXST",
"main_instance_badge": "Instance principale",
"nodejs_runtime": "Node.js",
"page_count_label": "Nombre de pages",
@@ -1333,6 +1374,24 @@
"vector_import_empty": "Aucune entité trouvée dans le fichier.",
"vector_import_failed": "Impossible de lire le fichier vectoriel.",
"vector_export_ok": "Exportation démarrée.",
+ "remote_overlays_title": "Calques distants",
+ "remote_overlays_reload": "Recharger",
+ "remote_overlays_kind": "Type de source",
+ "remote_overlays_url": "URL de la source",
+ "remote_overlays_paths": "Chemins de fichiers du dépôt (un par ligne)",
+ "remote_overlays_ref": "Réf. Git (branche, étiquette ou commit)",
+ "remote_overlays_refresh_interval": "Intervalle d’actualisation auto (secondes, 0 = off)",
+ "remote_overlays_import": "Importer / récupérer",
+ "remote_overlays_importing": "Récupération…",
+ "remote_overlays_empty": "Aucun calque distant pour le moment.",
+ "remote_overlays_visible": "Afficher",
+ "remote_overlays_refresh": "Actualiser",
+ "remote_overlays_copy_drawings": "Copier dans les dessins",
+ "remote_overlays_delete": "Supprimer",
+ "remote_overlays_error": "Erreur de calque distant",
+ "remote_overlays_export_ok": "Export du calque démarré.",
+ "remote_overlays_export_failed": "Échec de l’export du calque.",
+ "remote_overlays_copied": "Calque copié dans les dessins.",
"drop_geo_files": "Déposez le fichier carte ici",
"drop_map_files_hint": "GeoJSON, KML, KMZ ou MBTiles",
"drop_no_geo_files": "Aucun fichier GeoJSON, KML ou KMZ détecté.",
@@ -1413,6 +1472,8 @@
"share_contact": "Partager le contact",
"share_contact_modal_title": "Partager le contact",
"share_contact_search_placeholder": "Rechercher des contacts…",
+ "share_apk": "Partager l'app (APK)",
+ "share_apk_failed": "Impossible de partager l'APK.",
"custom_display_name": "Affichage personnalisé",
"stranger_banner_text": "Ce pair n'est pas dans vos contacts. Les pièces jointes des étrangers sont bloquées.",
"add_to_contacts": "Ajouter aux contacts",
@@ -1633,7 +1694,9 @@
"conversation_file_other": "{name} a envoyé un fichier",
"conversation_files_you": "Vous avez envoyé {count} fichiers",
"conversation_files_other": "{name} a envoyé {count} fichiers",
- "message_not_found_in_cache": "Message non trouvé dans cache"
+ "message_not_found_in_cache": "Message non trouvé dans cache",
+ "failed_to_send": "Échec de l'envoi du message",
+ "failed_to_send_image": "Échec de l'envoi de l'image {index} : {detail}"
},
"settings": {
"shortcut_saved": "Raccourci enregistré",
@@ -1698,7 +1761,9 @@
"plugins": "Extensions",
"plugins_desc": "Installer et gérer les extensions MeshChatX"
},
- "failed_update_reticulum_instance": "Échec de la mise à jour des paramètres de l'instance Reticulum"
+ "failed_update_reticulum_instance": "Échec de la mise à jour des paramètres de l'instance Reticulum",
+ "keyboard_shortcuts_title": "Raccourcis clavier",
+ "keyboard_shortcuts_description": "Personnalisez les actions clavier. Replié par défaut sur mobile."
},
"debug": {
"title": "Débogues",
@@ -1928,7 +1993,8 @@
"tab_switch_failed": "Impossible de passer à cet onglet",
"tab_content_mismatch": "Cet onglet était désynchronisé et est en cours de rechargement",
"tab_restore_failed": "Impossible de restaurer la page de cet onglet",
- "open_node_failed": "Impossible d'ouvrir le nœud NomadNet"
+ "open_node_failed": "Impossible d'ouvrir le nœud NomadNet",
+ "hide_source": "Masquer le code source"
},
"forwarder": {
"title": "Expéditeur LXMF",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index d8d61f20..46fcad6f 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "Revoca Fiducia Telemetrica",
"telemetry_trust_grant": "Fidati per la Telemetria",
"location_manage_desc": "Gestisci come viene condivisa la tua posizione.",
+ "map_settings_title": "Mappa",
+ "map_settings_desc": "Impostazioni predefinite della mappa, tile, modalità offline e limiti degli overlay remoti.",
+ "map_defaults_heading": "Predefinite della mappa",
+ "map_tiles_heading": "Tile e offline",
+ "map_overlay_limits_heading": "Limiti overlay remoti",
+ "map_overlay_limits_desc": "Limiti di protezione per importazioni KMZ/KML/GeoJSON da NomadNet e RNGit. I valori sono limitati a intervalli sicuri.",
+ "map_overlay_max_bytes": "Dimensione massima file overlay (byte)",
+ "map_overlay_max_features": "Feature massime per overlay",
+ "map_overlay_max_kmz_uncompressed_bytes": "Dimensione massima KMZ non compresso (byte)",
+ "map_overlay_max_sources": "Sorgenti overlay massime",
+ "map_overlay_max_concurrent_jobs": "Job overlay simultanei massimi",
+ "map_overlay_path_timeout_seconds": "Timeout ricerca percorso (secondi)",
+ "map_overlay_transfer_timeout_seconds": "Timeout trasferimento (secondi)",
+ "map_overlay_job_timeout_seconds": "Timeout job (secondi)",
+ "map_overlay_max_retries": "Tentativi di recupero massimi",
+ "map_overlay_retry_delay_seconds": "Ritardo base ritentativo (secondi)",
+ "map_default_lat": "Latitudine predefinita",
+ "map_default_lon": "Longitudine predefinita",
+ "map_default_zoom": "Zoom predefinito",
+ "map_tile_server_url": "URL server tile",
+ "map_nominatim_api_url": "URL API Nominatim",
+ "map_offline_enabled": "MBTiles offline abilitati",
+ "map_tile_cache_enabled": "Cache tile abilitata",
"restart_rns": "Riavvia RNS",
"flood_protection": "Protezione dall'inondazione",
"flood_protection_description": "Aumenta automaticamente il costo del timbro in entrata quando si ricevono troppi messaggi al minuto da molte fonti. Ciò rende gli attacchi spam coordinati computazionalmente costosi, mantenendo le conversazioni normali accessibili.",
@@ -456,7 +479,12 @@
"rpc_key_show": "Mostra chiave RPC",
"rpc_key_hide": "Nascondi chiave RPC",
"refresh_community_interfaces": "Aggiorna da directory.rns.recipes",
- "refresh_community_interfaces_busy": "Aggiornamento…"
+ "refresh_community_interfaces_busy": "Aggiornamento…",
+ "network_degraded": "Rete mesh non disponibile. L'app resta attiva così puoi riparare le interfacce senza cancellare i dati.",
+ "recover_network": "Riprova rete",
+ "open_interfaces": "Apri interfacce",
+ "network_recovered": "Stack di rete ripristinato",
+ "network_recover_failed": "Impossibile ripristinare lo stack di rete. Controlla le interfacce e riprova."
},
"common": {
"open": "Apri",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Invia pacchetti tramite collegamenti RNS",
"managers.rnsLink.close": "Chiudi collegamenti RNS",
"storage.isolated": "Usa archiviazione isolata per plugin",
- "network.fetch": "Effettua richieste HTTP internet in uscita"
+ "network.fetch": "Effettua richieste HTTP internet in uscita",
+ "managers.debugLog.read": "Leggi i log di debug dell'applicazione",
+ "managers.bugReport.status": "Leggi lo stato del collector dei bug report",
+ "managers.bugReport.listCollectors": "Elenca i collector mcx-bugs-v1 rilevati",
+ "managers.bugReport.listReports": "Elenca i bug report ricevuti",
+ "managers.bugReport.preview": "Anteprima dei log di debug redatti per i bug report",
+ "managers.bugReport.send": "Invia bug report sulla mesh",
+ "managers.bugReport.startCollector": "Avvia un collector mcx-bugs-v1",
+ "managers.bugReport.stopCollector": "Ferma il collector dei bug report",
+ "managers.bugReport.announce": "Annuncia il collector dei bug report"
},
"install_dialog": {
"title": "Installa plugin",
@@ -780,7 +817,11 @@
"loaded": "Plugin Sideband caricati",
"saved": "Impostazioni plugin Sideband salvate",
"reloaded": "Plugin Sideband ricaricati",
- "danger_confirm": "I plugin Sideband vengono eseguiti come codice Python completo all'interno di MeshChatX con accesso a filesystem, rete e LXMF. Abilita solo se ti fidi di ogni script nella directory."
+ "danger_confirm": "I plugin Sideband vengono eseguiti come codice Python completo all'interno di MeshChatX con accesso a filesystem, rete e LXMF. Abilita solo se ti fidi di ogni script nella directory.",
+ "browse": "Sfoglia",
+ "browse_title": "Scegli cartella plugin Sideband",
+ "path_prompt": "Inserisci il percorso completo della cartella plugin Sideband",
+ "path_picked": "Cartella plugin selezionata"
}
},
"selftest": {
@@ -1036,7 +1077,7 @@
"integrity_data_error": "I tuoi file di identità o del database sembrano essere cambiati mentre l'app era chiusa.",
"integrity_warning_footer": "Questo è indicativo, non una compromissione confermata. Se hai aggiornato l'app o modificato questi file tu stesso, puoi confermare per reimpostare la baseline.",
"no_integrity_violations": "Nessuna modifica imprevista ai file monitorati dall'ultimo avvio.",
- "dependency_chain": "Catena di Dipendenze",
+ "dependency_chain": "Versioni stack",
"other_core_components": "Altri Componenti Core",
"backend_dependencies": "Dipendenze Backend",
"delete_snapshot_confirm": "Sei sicuro di voler eliminare questo snapshot?",
@@ -1065,7 +1106,7 @@
"automatic_backups_title": "Backup automatici",
"backup_download_failed": "Download del backup non riuscito",
"backup_downloaded": "Backup scaricato",
- "backend_stack": "Stack backend",
+ "backend_stack": "Pacchetti Python",
"chrome_runtime": "Chrome",
"contact_alternate": "Indirizzo alternativo",
"contact_details": "Dettagli",
@@ -1081,7 +1122,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} copiato negli appunti",
- "core_runtime": "Runtime principale",
+ "core_runtime": "Versioni runtime",
"creating": "Creazione in corso…",
"database_backups_desc": "Snapshot completi del database delle comunicazioni.",
"database_backups_title": "Backup database",
@@ -1107,7 +1148,7 @@
"local_snapshots_desc": "Crea punti di ripristino su disco in un dato momento.",
"local_snapshots_title": "Snapshot locali",
"lxmf_address": "Indirizzo LXMF",
- "lxst_engine": "Motore LXST",
+ "lxst_engine": "LXST",
"main_instance_badge": "Istanza principale",
"nodejs_runtime": "Node.js",
"page_count_label": "Conteggio pagine",
@@ -1385,6 +1426,24 @@
"vector_import_empty": "Nessuna caratteristica trovata nel file.",
"vector_import_failed": "Impossibile leggere il file vettoriale.",
"vector_export_ok": "Esportazione avviata.",
+ "remote_overlays_title": "Overlay remoti",
+ "remote_overlays_reload": "Ricarica",
+ "remote_overlays_kind": "Tipo di sorgente",
+ "remote_overlays_url": "URL sorgente",
+ "remote_overlays_paths": "Percorsi file nel repo (uno per riga)",
+ "remote_overlays_ref": "Riferimento Git (branch, tag o commit)",
+ "remote_overlays_refresh_interval": "Intervallo aggiornamento automatico (secondi, 0 = off)",
+ "remote_overlays_import": "Importa / scarica",
+ "remote_overlays_importing": "Download in corso…",
+ "remote_overlays_empty": "Nessun overlay remoto ancora.",
+ "remote_overlays_visible": "Mostra",
+ "remote_overlays_refresh": "Aggiorna",
+ "remote_overlays_copy_drawings": "Copia nei disegni",
+ "remote_overlays_delete": "Elimina",
+ "remote_overlays_error": "Errore overlay remoto",
+ "remote_overlays_export_ok": "Esportazione overlay avviata.",
+ "remote_overlays_export_failed": "Esportazione overlay non riuscita.",
+ "remote_overlays_copied": "Overlay copiato nei disegni.",
"drop_geo_files": "Rilascia il file della mappa qui",
"drop_map_files_hint": "GeoJSON, KML, KMZ o MBTiles",
"drop_no_geo_files": "Nessun file GeoJSON, KML o KMZ rilevato.",
@@ -1506,6 +1565,8 @@
"share_contact": "Condividi contatto",
"share_contact_modal_title": "Condividi contatto",
"share_contact_search_placeholder": "Cerca contatti…",
+ "share_apk": "Condividi app (APK)",
+ "share_apk_failed": "Impossibile condividere l'APK.",
"opportunistic_deferred_label": "In attesa",
"opportunistic_deferred_tooltip": "Il messaggio verrà inviato quando l'utente sarà online o invierà un annuncio.",
"failed_waiting_announce": "Fallito, in attesa di annuncio",
@@ -1685,7 +1746,9 @@
"conversation_file_other": "{name} ha inviato un file",
"conversation_files_you": "Hai inviato {count} file",
"conversation_files_other": "{name} ha inviato {count} file",
- "message_not_found_in_cache": "Messaggio non trovato nella cache"
+ "message_not_found_in_cache": "Messaggio non trovato nella cache",
+ "failed_to_send": "Impossibile inviare il messaggio",
+ "failed_to_send_image": "Impossibile inviare l'immagine {index}: {detail}"
},
"settings": {
"shortcut_saved": "Scorciatoia salvata",
@@ -1750,7 +1813,9 @@
"plugins": "Plugin",
"plugins_desc": "Installa e gestisci i plugin di MeshChatX"
},
- "failed_update_reticulum_instance": "Impossibile aggiornare le impostazioni dell'istanza Reticulum!"
+ "failed_update_reticulum_instance": "Impossibile aggiornare le impostazioni dell'istanza Reticulum!",
+ "keyboard_shortcuts_title": "Scorciatoie da tastiera",
+ "keyboard_shortcuts_description": "Personalizza le azioni rapide da tastiera. Compresso di default su telefono."
},
"debug": {
"title": "Log di Debug",
@@ -1980,7 +2045,8 @@
"tab_switch_failed": "Impossibile passare a quella scheda",
"tab_content_mismatch": "Questa scheda non era sincronizzata e viene ricaricata",
"tab_restore_failed": "Impossibile ripristinare la pagina di questa scheda",
- "open_node_failed": "Impossibile aprire il nodo NomadNet"
+ "open_node_failed": "Impossibile aprire il nodo NomadNet",
+ "hide_source": "Nascondi sorgente"
},
"forwarder": {
"title": "Inoltro LXMF",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 272d07b9..171885de 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "Revoce Telemetrie Trust",
"telemetry_trust_grant": "Vertrouwen voor telemetrie",
"location_manage_desc": "Beheer hoe uw locatie wordt gedeeld.",
+ "map_settings_title": "Kaart",
+ "map_settings_desc": "Kaartstandaarden, tegels, offlinemodus en limieten voor externe overlays.",
+ "map_defaults_heading": "Kaartstandaarden",
+ "map_tiles_heading": "Tegels en offline",
+ "map_overlay_limits_heading": "Limieten voor externe overlays",
+ "map_overlay_limits_desc": "Beschermingslimieten voor KMZ/KML/GeoJSON-imports via NomadNet en RNGit. Waarden worden begrensd tot veilige bereiken.",
+ "map_overlay_max_bytes": "Max. overlay-bestandsgrootte (bytes)",
+ "map_overlay_max_features": "Max. objecten per overlay",
+ "map_overlay_max_kmz_uncompressed_bytes": "Max. ongecomprimeerde KMZ-grootte (bytes)",
+ "map_overlay_max_sources": "Max. overlaybronnen",
+ "map_overlay_max_concurrent_jobs": "Max. gelijktijdige overlaytaken",
+ "map_overlay_path_timeout_seconds": "Time-out padzoeken (seconden)",
+ "map_overlay_transfer_timeout_seconds": "Time-out overdracht (seconden)",
+ "map_overlay_job_timeout_seconds": "Time-out taak (seconden)",
+ "map_overlay_max_retries": "Max. ophaalpogingen",
+ "map_overlay_retry_delay_seconds": "Basisvertraging bij opnieuw proberen (seconden)",
+ "map_default_lat": "Standaardbreedtegraad",
+ "map_default_lon": "Standaardlengtegraad",
+ "map_default_zoom": "Standaardzoom",
+ "map_tile_server_url": "URL van tegelserver",
+ "map_nominatim_api_url": "Nominatim-API-URL",
+ "map_offline_enabled": "Offline MBTiles ingeschakeld",
+ "map_tile_cache_enabled": "Tegelcache ingeschakeld",
"restart_rns": "RNS herstarten",
"flood_protection": "Overstromingsbeveiliging",
"flood_protection_description": "Verhoog automatisch de inkomende zegelkosten wanneer er te veel berichten per minuut van veel bronnen worden ontvangen. Dit maakt gecoördineerde spam-aanvallen rekenkundig duur, terwijl normale gesprekken betaalbaar blijven.",
@@ -456,7 +479,12 @@
"rpc_key_show": "RPC-sleutel tonen",
"rpc_key_hide": "RPC-sleutel verbergen",
"refresh_community_interfaces": "Vernieuwen vanaf directory.rns.recipes",
- "refresh_community_interfaces_busy": "Bezig met vernieuwen…"
+ "refresh_community_interfaces_busy": "Bezig met vernieuwen…",
+ "network_degraded": "Mesh-netwerk niet beschikbaar. De app blijft draaien zodat je interfaces kunt herstellen zonder gegevens te wissen.",
+ "recover_network": "Netwerk opnieuw proberen",
+ "open_interfaces": "Interfaces openen",
+ "network_recovered": "Netwerkstack hersteld",
+ "network_recover_failed": "Netwerkstack kon niet worden hersteld. Controleer interfaces en probeer opnieuw."
},
"common": {
"open": "Openen",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Pakketten verzenden via RNS-links",
"managers.rnsLink.close": "RNS-links sluiten",
"storage.isolated": "Geïsoleerde pluginopslag gebruiken",
- "network.fetch": "Uitgaande internet-HTTP-verzoeken doen"
+ "network.fetch": "Uitgaande internet-HTTP-verzoeken doen",
+ "managers.debugLog.read": "Applicatie-debuglogs lezen",
+ "managers.bugReport.status": "Status van bugrapport-collector lezen",
+ "managers.bugReport.listCollectors": "Gehoorde mcx-bugs-v1-collectors tonen",
+ "managers.bugReport.listReports": "Ontvangen bugrapporten tonen",
+ "managers.bugReport.preview": "Voorbeeld van geredigeerde debuglogs voor bugrapporten",
+ "managers.bugReport.send": "Bugrapporten via het mesh versturen",
+ "managers.bugReport.startCollector": "Een mcx-bugs-v1-collector starten",
+ "managers.bugReport.stopCollector": "De bugrapport-collector stoppen",
+ "managers.bugReport.announce": "De bugrapport-collector announcen"
},
"install_dialog": {
"title": "Plugin installeren",
@@ -780,7 +817,11 @@
"loaded": "Geladen Sideband-plug-ins",
"saved": "Sideband-plug-ininstellingen opgeslagen",
"reloaded": "Sideband-plug-ins herladen",
- "danger_confirm": "Sideband-plug-ins worden uitgevoerd als volledige Python-code binnen MeshChatX met bestandssysteem-, netwerk- en LXMF-toegang. Schakel alleen in als u elk script in de map vertrouwt."
+ "danger_confirm": "Sideband-plug-ins worden uitgevoerd als volledige Python-code binnen MeshChatX met bestandssysteem-, netwerk- en LXMF-toegang. Schakel alleen in als u elk script in de map vertrouwt.",
+ "browse": "Bladeren",
+ "browse_title": "Kies Sideband-pluginmap",
+ "path_prompt": "Voer het volledige pad naar de Sideband-pluginmap in",
+ "path_picked": "Pluginmap geselecteerd"
}
},
"selftest": {
@@ -984,7 +1025,7 @@
"integrity_data_error": "Uw identiteits- of databasebestanden lijken te zijn gewijzigd terwijl de app gesloten was.",
"integrity_warning_footer": "Dit is informatief, geen bevestigde compromittering. Als u de app hebt bijgewerkt of deze bestanden zelf hebt bewerkt, kunt u bevestigen om de basislijn te resetten.",
"no_integrity_violations": "Geen onverwachte wijzigingen in bewaakte bestanden sinds de laatste start.",
- "dependency_chain": "Afhankelijkheidsketen",
+ "dependency_chain": "Stackversies",
"other_core_components": "Andere kerncomponenten",
"backend_dependencies": "Backend afhankelijkheden",
"delete_snapshot_confirm": "Weet u zeker dat u deze snapshot wilt verwijderen?",
@@ -1013,7 +1054,7 @@
"automatic_backups_title": "Automatische back-ups",
"backup_download_failed": "Back-up downloaden mislukt",
"backup_downloaded": "Back-up gedownload",
- "backend_stack": "Backend-stack",
+ "backend_stack": "Python-pakketten",
"chrome_runtime": "Chrome",
"contact_alternate": "Alternatief adres",
"contact_details": "Details",
@@ -1029,7 +1070,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} naar klembord gekopieerd",
- "core_runtime": "Kernruntime",
+ "core_runtime": "Runtime-versies",
"creating": "Bezig met maken…",
"database_backups_desc": "Volledige snapshots van je communicatiedatabase.",
"database_backups_title": "Databaseback-ups",
@@ -1055,7 +1096,7 @@
"local_snapshots_desc": "Maak herstelpunten op schijf op een gekozen tijdstip.",
"local_snapshots_title": "Lokale snapshots",
"lxmf_address": "LXMF-adres",
- "lxst_engine": "LXST-engine",
+ "lxst_engine": "LXST",
"main_instance_badge": "Hoofdinstantie",
"nodejs_runtime": "Node.js",
"page_count_label": "Pagina's",
@@ -1333,6 +1374,24 @@
"vector_import_empty": "Geen objecten gevonden in bestand.",
"vector_import_failed": "Kon vectorbestand niet lezen.",
"vector_export_ok": "Exporteren gestart.",
+ "remote_overlays_title": "Externe overlays",
+ "remote_overlays_reload": "Herladen",
+ "remote_overlays_kind": "Brontype",
+ "remote_overlays_url": "Bron-URL",
+ "remote_overlays_paths": "Repobestandspaden (één per regel)",
+ "remote_overlays_ref": "Git-ref (branch, tag of commit)",
+ "remote_overlays_refresh_interval": "Autoverversingsinterval (seconden, 0 = uit)",
+ "remote_overlays_import": "Importeren / ophalen",
+ "remote_overlays_importing": "Ophalen…",
+ "remote_overlays_empty": "Nog geen externe overlays.",
+ "remote_overlays_visible": "Tonen",
+ "remote_overlays_refresh": "Vernieuwen",
+ "remote_overlays_copy_drawings": "Kopiëren naar tekeningen",
+ "remote_overlays_delete": "Verwijderen",
+ "remote_overlays_error": "Fout bij externe overlay",
+ "remote_overlays_export_ok": "Overlay-export gestart.",
+ "remote_overlays_export_failed": "Overlay-export mislukt.",
+ "remote_overlays_copied": "Overlay gekopieerd naar tekeningen.",
"drop_geo_files": "Sleep kaartbestand hierheen",
"drop_map_files_hint": "GeoJSON, KML, KMZ of MBTiles",
"drop_no_geo_files": "Geen GeoJSON-, KML- of KMZ-bestanden gedetecteerd.",
@@ -1413,6 +1472,8 @@
"share_contact": "Contact delen",
"share_contact_modal_title": "Contact delen",
"share_contact_search_placeholder": "Contacten zoeken…",
+ "share_apk": "App delen (APK)",
+ "share_apk_failed": "Kon APK niet delen.",
"custom_display_name": "Aangepaste schermnaam",
"stranger_banner_text": "Deze peer staat niet in je contacten. De bijlagen van vreemden zijn geblokkeerd.",
"add_to_contacts": "Toevoegen aan contacten",
@@ -1633,7 +1694,9 @@
"conversation_file_other": "{name} heeft een bestand gestuurd",
"conversation_files_you": "Je hebt {count} bestanden gestuurd",
"conversation_files_other": "{name} heeft {count} bestanden gestuurd",
- "message_not_found_in_cache": "Bericht niet gevonden in cache"
+ "message_not_found_in_cache": "Bericht niet gevonden in cache",
+ "failed_to_send": "Bericht verzenden mislukt",
+ "failed_to_send_image": "Afbeelding {index} verzenden mislukt: {detail}"
},
"settings": {
"shortcut_saved": "Sneltoets opgeslagen",
@@ -1698,7 +1761,9 @@
"plugins": "Plug-ins",
"plugins_desc": "MeshChatX-plugins installeren en beheren"
},
- "failed_update_reticulum_instance": "Instellingen van Reticulum-instantie bijwerken mislukt!"
+ "failed_update_reticulum_instance": "Instellingen van Reticulum-instantie bijwerken mislukt!",
+ "keyboard_shortcuts_title": "Sneltoetsen",
+ "keyboard_shortcuts_description": "Pas snelle toetsenbordacties aan. Standaard ingeklapt op telefoons."
},
"debug": {
"title": "Debuglogs",
@@ -1928,7 +1993,8 @@
"tab_switch_failed": "Kon niet naar dat tabblad wisselen",
"tab_content_mismatch": "Dit tabblad was niet gesynchroniseerd en wordt opnieuw geladen",
"tab_restore_failed": "Kon de pagina van dit tabblad niet herstellen",
- "open_node_failed": "Kon het NomadNet-knooppunt niet openen"
+ "open_node_failed": "Kon het NomadNet-knooppunt niet openen",
+ "hide_source": "Bron verbergen"
},
"forwarder": {
"title": "LXMF-doorstuurder",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 1e295b66..8cf21669 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "Отозвать доверие телеметрии",
"telemetry_trust_grant": "Доверять телеметрии",
"location_manage_desc": "Управление тем, как передается ваше местоположение.",
+ "map_settings_title": "Карта",
+ "map_settings_desc": "Параметры карты по умолчанию, тайлы, офлайн-режим и лимиты удалённых оверлеев.",
+ "map_defaults_heading": "Параметры карты по умолчанию",
+ "map_tiles_heading": "Тайлы и офлайн",
+ "map_overlay_limits_heading": "Лимиты удалённых оверлеев",
+ "map_overlay_limits_desc": "Ограничения для импорта KMZ/KML/GeoJSON из NomadNet и RNGit. Значения ограничиваются безопасным диапазоном.",
+ "map_overlay_max_bytes": "Макс. размер файла оверлея (байты)",
+ "map_overlay_max_features": "Макс. число объектов в оверлее",
+ "map_overlay_max_kmz_uncompressed_bytes": "Макс. несжатый размер KMZ (байты)",
+ "map_overlay_max_sources": "Макс. число источников оверлеев",
+ "map_overlay_max_concurrent_jobs": "Макс. одновременных заданий оверлея",
+ "map_overlay_path_timeout_seconds": "Таймаут поиска пути (секунды)",
+ "map_overlay_transfer_timeout_seconds": "Таймаут передачи (секунды)",
+ "map_overlay_job_timeout_seconds": "Таймаут задания (секунды)",
+ "map_overlay_max_retries": "Макс. число повторов загрузки",
+ "map_overlay_retry_delay_seconds": "Базовая задержка повтора (секунды)",
+ "map_default_lat": "Широта по умолчанию",
+ "map_default_lon": "Долгота по умолчанию",
+ "map_default_zoom": "Масштаб по умолчанию",
+ "map_tile_server_url": "URL сервера тайлов",
+ "map_nominatim_api_url": "URL API Nominatim",
+ "map_offline_enabled": "Офлайн MBTiles включены",
+ "map_tile_cache_enabled": "Кэш тайлов включён",
"restart_rns": "Перезапуск RNS",
"flood_protection": "Защита от флуда",
"flood_protection_description": "Автоматически повышать стоимость входящего штампа при получении слишком большого количества сообщений в минуту из многих источников. Это делает координированные спам-атаки вычислительно дорогими, сохраняя обычные разговоры доступными.",
@@ -456,7 +479,12 @@
"rpc_key_show": "Показать RPC-ключ",
"rpc_key_hide": "Скрыть RPC-ключ",
"refresh_community_interfaces": "Обновить из directory.rns.recipes",
- "refresh_community_interfaces_busy": "Обновление…"
+ "refresh_community_interfaces_busy": "Обновление…",
+ "network_degraded": "Mesh-сеть недоступна. Приложение продолжает работать, чтобы вы могли исправить интерфейсы без удаления данных.",
+ "recover_network": "Повторить сеть",
+ "open_interfaces": "Открыть интерфейсы",
+ "network_recovered": "Сетевой стек восстановлен",
+ "network_recover_failed": "Не удалось восстановить сетевой стек. Проверьте интерфейсы и попробуйте снова."
},
"common": {
"open": "Открыть",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "Отправлять пакеты по ссылкам RNS",
"managers.rnsLink.close": "Закрывать ссылки RNS",
"storage.isolated": "Использовать изолированное хранилище плагина",
- "network.fetch": "Выполнять исходящие HTTP-запросы в интернет"
+ "network.fetch": "Выполнять исходящие HTTP-запросы в интернет",
+ "managers.debugLog.read": "Читать отладочные журналы приложения",
+ "managers.bugReport.status": "Читать статус сборщика отчётов об ошибках",
+ "managers.bugReport.listCollectors": "Список услышанных сборщиков mcx-bugs-v1",
+ "managers.bugReport.listReports": "Список полученных отчётов об ошибках",
+ "managers.bugReport.preview": "Предпросмотр обезличенных логов для отчётов",
+ "managers.bugReport.send": "Отправлять отчёты об ошибках по сети",
+ "managers.bugReport.startCollector": "Запустить сборщик mcx-bugs-v1",
+ "managers.bugReport.stopCollector": "Остановить сборщик отчётов об ошибках",
+ "managers.bugReport.announce": "Анонсировать сборщик отчётов об ошибках"
},
"install_dialog": {
"title": "Установить плагин",
@@ -780,7 +817,11 @@
"loaded": "Загруженные плагины Sideband",
"saved": "Настройки плагинов Sideband сохранены",
"reloaded": "Плагины Sideband перезагружены",
- "danger_confirm": "Плагины Sideband выполняются как полноценный код Python внутри MeshChatX с доступом к файловой системе, сети и LXMF. Включайте, только если доверяете каждому скрипту в каталоге."
+ "danger_confirm": "Плагины Sideband выполняются как полноценный код Python внутри MeshChatX с доступом к файловой системе, сети и LXMF. Включайте, только если доверяете каждому скрипту в каталоге.",
+ "browse": "Обзор",
+ "browse_title": "Выберите папку плагинов Sideband",
+ "path_prompt": "Введите полный путь к папке плагинов Sideband",
+ "path_picked": "Папка плагинов выбрана"
}
},
"selftest": {
@@ -1033,7 +1074,7 @@
"tampering_detected": "Обнаружены изменения",
"technical_issues": "Технические проблемы:",
"no_integrity_violations": "Неожиданных изменений в отслеживаемых файлах с последнего запуска не обнаружено.",
- "dependency_chain": "Цепочка зависимостей",
+ "dependency_chain": "Версии стека",
"other_core_components": "Другие основные компоненты",
"backend_dependencies": "Зависимости бэкенда",
"integrity_backend_error": "Бинарный файл бэкенда приложения (распакованный из ASAR), по-видимому, изменился с момента последнего снимка. Если вы его не обновляли и не изменяли, проверьте изменение.",
@@ -1065,7 +1106,7 @@
"automatic_backups_title": "Автоматические резервные копии",
"backup_download_failed": "Не удалось скачать резервную копию",
"backup_downloaded": "Резервная копия скачана",
- "backend_stack": "Стек бэкенда",
+ "backend_stack": "Пакеты Python",
"chrome_runtime": "Chrome",
"contact_alternate": "Альтернативный адрес",
"contact_details": "Подробности",
@@ -1081,7 +1122,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} скопировано в буфер обмена",
- "core_runtime": "Основная среда выполнения",
+ "core_runtime": "Версии среды",
"creating": "Создание…",
"database_backups_desc": "Полные снимки базы данных ваших сообщений.",
"database_backups_title": "Резервные копии базы данных",
@@ -1107,7 +1148,7 @@
"local_snapshots_desc": "Создавайте снимки на диске в выбранный момент времени.",
"local_snapshots_title": "Локальные снимки",
"lxmf_address": "Адрес LXMF",
- "lxst_engine": "Движок LXST",
+ "lxst_engine": "LXST",
"main_instance_badge": "Основной экземпляр",
"nodejs_runtime": "Node.js",
"page_count_label": "Число страниц",
@@ -1385,6 +1426,24 @@
"vector_import_empty": "В файле не найдено объектов.",
"vector_import_failed": "Не удалось прочитать векторный файл.",
"vector_export_ok": "Экспорт начат.",
+ "remote_overlays_title": "Удалённые оверлеи",
+ "remote_overlays_reload": "Обновить список",
+ "remote_overlays_kind": "Тип источника",
+ "remote_overlays_url": "URL источника",
+ "remote_overlays_paths": "Пути файлов в репозитории (по одному в строке)",
+ "remote_overlays_ref": "Git-ссылка (ветка, тег или коммит)",
+ "remote_overlays_refresh_interval": "Интервал автообновления (секунды, 0 = выкл.)",
+ "remote_overlays_import": "Импорт / загрузка",
+ "remote_overlays_importing": "Загрузка…",
+ "remote_overlays_empty": "Удалённых оверлеев пока нет.",
+ "remote_overlays_visible": "Показать",
+ "remote_overlays_refresh": "Обновить",
+ "remote_overlays_copy_drawings": "Копировать в рисунки",
+ "remote_overlays_delete": "Удалить",
+ "remote_overlays_error": "Ошибка удалённого оверлея",
+ "remote_overlays_export_ok": "Экспорт оверлея начат.",
+ "remote_overlays_export_failed": "Не удалось экспортировать оверлей.",
+ "remote_overlays_copied": "Оверлей скопирован в рисунки.",
"drop_geo_files": "Перетащите файл карты сюда",
"drop_map_files_hint": "GeoJSON, KML, KMZ или MBTiles",
"drop_no_geo_files": "Не обнаружено файлов GeoJSON, KML или KMZ.",
@@ -1506,6 +1565,8 @@
"share_contact": "Поделиться контактом",
"share_contact_modal_title": "Поделиться контактом",
"share_contact_search_placeholder": "Поиск контактов…",
+ "share_apk": "Поделиться приложением (APK)",
+ "share_apk_failed": "Не удалось поделиться APK.",
"opportunistic_deferred_label": "Ожидание",
"opportunistic_deferred_tooltip": "Сообщение отправится, когда пользователь будет в сети или отправит объявление.",
"failed_waiting_announce": "Ошибка, ожидание объявления",
@@ -1685,7 +1746,9 @@
"conversation_file_other": "{name} отправил(а) файл",
"conversation_files_you": "Вы отправили файлов: {count}",
"conversation_files_other": "{name} отправил(а) файлов: {count}",
- "message_not_found_in_cache": "Сообщение не найдено в кэше"
+ "message_not_found_in_cache": "Сообщение не найдено в кэше",
+ "failed_to_send": "Не удалось отправить сообщение",
+ "failed_to_send_image": "Не удалось отправить изображение {index}: {detail}"
},
"nomadnet": {
"remove_favourite": "Удалить из избранного",
@@ -1813,7 +1876,8 @@
"tab_switch_failed": "Не удалось переключиться на эту вкладку",
"tab_content_mismatch": "Вкладка была не синхронизирована и перезагружается",
"tab_restore_failed": "Не удалось восстановить страницу вкладки",
- "open_node_failed": "Не удалось открыть узел NomadNet"
+ "open_node_failed": "Не удалось открыть узел NomadNet",
+ "hide_source": "Скрыть исходник"
},
"forwarder": {
"title": "LXMF Форвардер",
@@ -3076,7 +3140,9 @@
"plugins": "Плагины",
"plugins_desc": "Установка и управление плагинами MeshChatX"
},
- "failed_update_reticulum_instance": "Не удалось обновить настройки экземпляра Reticulum!"
+ "failed_update_reticulum_instance": "Не удалось обновить настройки экземпляра Reticulum!",
+ "keyboard_shortcuts_title": "Горячие клавиши",
+ "keyboard_shortcuts_description": "Настройте быстрые клавиши. На телефонах свёрнуто по умолчанию."
},
"debug": {
"title": "Журнал отладки",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 7057fbb0..e683a51f 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -416,6 +416,29 @@
"telemetry_trust_revoke": "撤销遥测信任",
"telemetry_trust_grant": "授予遥测信任",
"location_manage_desc": "管理您的位置共享方式。",
+ "map_settings_title": "地图",
+ "map_settings_desc": "地图默认值、瓦片、离线模式以及远程叠加层限制。",
+ "map_defaults_heading": "地图默认值",
+ "map_tiles_heading": "瓦片与离线",
+ "map_overlay_limits_heading": "远程叠加层限制",
+ "map_overlay_limits_desc": "针对 NomadNet 与 RNGit 的 KMZ/KML/GeoJSON 导入保护限制。数值会被限制在安全范围内。",
+ "map_overlay_max_bytes": "叠加层文件最大大小(字节)",
+ "map_overlay_max_features": "每个叠加层最大要素数",
+ "map_overlay_max_kmz_uncompressed_bytes": "KMZ 最大未压缩大小(字节)",
+ "map_overlay_max_sources": "最大叠加层来源数",
+ "map_overlay_max_concurrent_jobs": "最大并发叠加层任务数",
+ "map_overlay_path_timeout_seconds": "路径查找超时(秒)",
+ "map_overlay_transfer_timeout_seconds": "传输超时(秒)",
+ "map_overlay_job_timeout_seconds": "任务超时(秒)",
+ "map_overlay_max_retries": "最大获取重试次数",
+ "map_overlay_retry_delay_seconds": "重试基础延迟(秒)",
+ "map_default_lat": "默认纬度",
+ "map_default_lon": "默认经度",
+ "map_default_zoom": "默认缩放",
+ "map_tile_server_url": "瓦片服务器 URL",
+ "map_nominatim_api_url": "Nominatim API URL",
+ "map_offline_enabled": "已启用离线 MBTiles",
+ "map_tile_cache_enabled": "已启用瓦片缓存",
"restart_rns": "重启 RNS",
"flood_protection": "洪水保护",
"flood_protection_description": "当从多个来源每分钟收到过多消息时,自动提高入站邮票费用。这使得协调的垃圾邮件攻击在计算上成本高昂,同时保持正常对话的可负担性。",
@@ -456,7 +479,12 @@
"rpc_key_show": "显示 RPC 密钥",
"rpc_key_hide": "隐藏 RPC 密钥",
"refresh_community_interfaces": "从 directory.rns.recipes 刷新",
- "refresh_community_interfaces_busy": "正在刷新…"
+ "refresh_community_interfaces_busy": "正在刷新…",
+ "network_degraded": "Mesh 网络不可用。应用仍在运行,便于修复接口而无需清除数据。",
+ "recover_network": "重试网络",
+ "open_interfaces": "打开接口",
+ "network_recovered": "网络栈已恢复",
+ "network_recover_failed": "无法恢复网络栈。请检查接口后重试。"
},
"common": {
"open": "打开",
@@ -746,7 +774,16 @@
"managers.rnsLink.send": "通过RNS链路发送数据包",
"managers.rnsLink.close": "关闭 RNS 链路",
"storage.isolated": "使用隔离的插件存储",
- "network.fetch": "发起出站互联网HTTP请求"
+ "network.fetch": "发起出站互联网HTTP请求",
+ "managers.debugLog.read": "读取应用调试日志",
+ "managers.bugReport.status": "读取错误报告收集器状态",
+ "managers.bugReport.listCollectors": "列出已发现的 mcx-bugs-v1 收集器",
+ "managers.bugReport.listReports": "列出已收到的错误报告",
+ "managers.bugReport.preview": "预览经脱敏的调试日志",
+ "managers.bugReport.send": "通过 mesh 发送错误报告",
+ "managers.bugReport.startCollector": "启动 mcx-bugs-v1 收集器",
+ "managers.bugReport.stopCollector": "停止错误报告收集器",
+ "managers.bugReport.announce": "通告错误报告收集器"
},
"install_dialog": {
"title": "安装插件",
@@ -780,7 +817,11 @@
"loaded": "已加载的Sideband插件",
"saved": "Sideband插件设置已保存",
"reloaded": "Sideband插件已重新加载",
- "danger_confirm": "Sideband插件作为完整Python代码在MeshChatX内部运行,具有文件系统、网络和LXMF访问权限。仅当您信任目录中的每个脚本时才启用。"
+ "danger_confirm": "Sideband插件作为完整Python代码在MeshChatX内部运行,具有文件系统、网络和LXMF访问权限。仅当您信任目录中的每个脚本时才启用。",
+ "browse": "浏览",
+ "browse_title": "选择 Sideband 插件文件夹",
+ "path_prompt": "输入 Sideband 插件文件夹的完整路径",
+ "path_picked": "已选择插件文件夹"
}
},
"selftest": {
@@ -984,7 +1025,7 @@
"integrity_data_error": "您的身份或数据库文件似乎在应用程序关闭时已更改。",
"integrity_warning_footer": "这仅供参考,并非已确认的安全入侵。如果您更新了应用程序或自行编辑了这些文件,可以确认以重置基线。",
"no_integrity_violations": "自上次启动以来,受监控的文件未发现意外更改。",
- "dependency_chain": "依赖链",
+ "dependency_chain": "堆栈版本",
"other_core_components": "其他核心组件",
"backend_dependencies": "后端依赖",
"delete_snapshot_confirm": "您确定要删除此快照吗?",
@@ -1013,7 +1054,7 @@
"automatic_backups_title": "自动备份",
"backup_download_failed": "备份下载失败",
"backup_downloaded": "备份已下载",
- "backend_stack": "后端技术栈",
+ "backend_stack": "Python 包",
"chrome_runtime": "Chrome",
"contact_alternate": "备用地址",
"contact_details": "详情",
@@ -1029,7 +1070,7 @@
"donate_kofi": "Ko-fi",
"donate_buymeacoffee": "Buy Me a Coffee",
"copied_label_to_clipboard": "{label} 已复制到剪贴板",
- "core_runtime": "核心运行时",
+ "core_runtime": "运行时版本",
"creating": "正在创建…",
"database_backups_desc": "通信数据库的完整快照。",
"database_backups_title": "数据库备份",
@@ -1055,7 +1096,7 @@
"local_snapshots_desc": "在磁盘上创建指定时间点的还原点。",
"local_snapshots_title": "本地快照",
"lxmf_address": "LXMF 地址",
- "lxst_engine": "LXST 引擎",
+ "lxst_engine": "LXST",
"main_instance_badge": "主实例",
"nodejs_runtime": "Node.js",
"page_count_label": "页数",
@@ -1333,6 +1374,24 @@
"vector_import_empty": "文件中未找到要素。",
"vector_import_failed": "无法读取矢量文件。",
"vector_export_ok": "导出已开始。",
+ "remote_overlays_title": "远程叠加层",
+ "remote_overlays_reload": "重新加载",
+ "remote_overlays_kind": "来源类型",
+ "remote_overlays_url": "来源 URL",
+ "remote_overlays_paths": "仓库文件路径(每行一个)",
+ "remote_overlays_ref": "Git 引用(分支、标签或提交)",
+ "remote_overlays_refresh_interval": "自动刷新间隔(秒,0 = 关闭)",
+ "remote_overlays_import": "导入 / 获取",
+ "remote_overlays_importing": "正在获取…",
+ "remote_overlays_empty": "尚无远程叠加层。",
+ "remote_overlays_visible": "显示",
+ "remote_overlays_refresh": "刷新",
+ "remote_overlays_copy_drawings": "复制到绘图",
+ "remote_overlays_delete": "删除",
+ "remote_overlays_error": "远程叠加层错误",
+ "remote_overlays_export_ok": "叠加层导出已开始。",
+ "remote_overlays_export_failed": "叠加层导出失败。",
+ "remote_overlays_copied": "已将叠加层复制到绘图。",
"drop_geo_files": "将地图文件拖放到此处",
"drop_map_files_hint": "GeoJSON、KML、KMZ 或 MBTiles",
"drop_no_geo_files": "未检测到 GeoJSON、KML 或 KMZ 文件。",
@@ -1413,6 +1472,8 @@
"share_contact": "分享联系人",
"share_contact_modal_title": "分享联系人",
"share_contact_search_placeholder": "搜索联系人…",
+ "share_apk": "分享应用(APK)",
+ "share_apk_failed": "无法打开 APK 分享。",
"custom_display_name": "自定义显示名称",
"stranger_banner_text": "此端点不在您的联系人中。陌生人附件已被阻止。",
"add_to_contacts": "添加到联系人",
@@ -1633,7 +1694,9 @@
"conversation_file_other": "{name} 发送了一个文件",
"conversation_files_you": "你发送了 {count} 个文件",
"conversation_files_other": "{name} 发送了 {count} 个文件",
- "message_not_found_in_cache": "缓存中未找到消息"
+ "message_not_found_in_cache": "缓存中未找到消息",
+ "failed_to_send": "发送消息失败",
+ "failed_to_send_image": "发送图片 {index} 失败:{detail}"
},
"settings": {
"shortcut_saved": "快捷键已保存",
@@ -1698,7 +1761,9 @@
"plugins": "插件",
"plugins_desc": "安装和管理 MeshChatX 插件"
},
- "failed_update_reticulum_instance": "更新 Reticulum 实例设置失败!"
+ "failed_update_reticulum_instance": "更新 Reticulum 实例设置失败!",
+ "keyboard_shortcuts_title": "键盘快捷键",
+ "keyboard_shortcuts_description": "自定义快捷键操作。手机上默认折叠。"
},
"debug": {
"title": "调试日志",
@@ -1928,7 +1993,8 @@
"tab_switch_failed": "无法切换到该标签页",
"tab_content_mismatch": "此标签页不同步,正在重新加载",
"tab_restore_failed": "无法恢复此标签页的页面",
- "open_node_failed": "无法打开 NomadNet 节点"
+ "open_node_failed": "无法打开 NomadNet 节点",
+ "hide_source": "隐藏源码"
},
"forwarder": {
"title": "LXMF 转发器",
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 35c99fe4..7dd9ffaa 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -299,10 +299,10 @@ const router = createRouter({
component: () => import("./components/call/CallPage.vue"),
},
{
- name: "plugin-mesh-observatory",
- path: "/plugins/com.meshchatx.mesh-observatory",
+ name: "plugin-mcx-bugs",
+ path: "/plugins/com.meshchatx.mcx-bugs",
component: () => import("./components/plugins/PluginPage.vue"),
- props: { pluginId: "com.meshchatx.mesh-observatory" },
+ props: { pluginId: "com.meshchatx.mcx-bugs" },
},
{
name: "changelog",
@@ -348,8 +348,15 @@ function markBootSplashError() {
const networkReady = await waitForNetworkReady({
onLine: setBootSplashLine,
onErrorState: markBootSplashError,
+ onDegraded: (error) => {
+ GlobalState.networkDegraded = true;
+ GlobalState.networkDegradedError = error || "Mesh network unavailable";
+ },
});
if (networkReady) {
+ if (networkReady === "degraded") {
+ GlobalState.networkDegraded = true;
+ }
try {
await fetchCsrfToken(window.api);
} catch {
@@ -413,6 +420,26 @@ if (networkReady) {
});
}
+ function removeBootSplash(splash) {
+ if (!splash || !splash.isConnected) {
+ return;
+ }
+ splash.setAttribute("aria-busy", "false");
+ splash.style.transition = "opacity 140ms ease";
+ splash.style.opacity = "0";
+ window.setTimeout(() => {
+ if (splash.isConnected) {
+ splash.remove();
+ }
+ }, 160);
+ }
+
+ function preloadCriticalRouteChunks() {
+ void import("./components/messages/MessagesPage.vue");
+ void import("./components/contacts/ContactsPage.vue");
+ void import("./components/interfaces/InterfacesPage.vue");
+ }
+
function bootstrap() {
registerMeshchatServiceWorker();
const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
@@ -429,11 +456,22 @@ if (networkReady) {
}
return;
}
- if (splash) {
- splash.remove();
- }
+ // Keep splash until the first painted frame so WebView does not flash white.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ removeBootSplash(splash);
+ });
+ });
+ preloadCriticalRouteChunks();
void startCodec2ScriptsBackgroundLoad();
void loadPluginsIfEnabled();
+ if (GlobalState.networkDegraded) {
+ try {
+ router.replace({ name: "interfaces" });
+ } catch {
+ // Route may not exist yet during early boot; banner still guides the user.
+ }
+ }
}
async function loadPluginsIfEnabled() {
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md b/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
index be4c7677..18aed096 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
@@ -10,6 +10,8 @@ MeshChatX is a heavily extended fork of Reticulum MeshChat. The goals below shap
- Keep the Python backend and Vue frontend independently testable.
- Run in constrained environments with predictable SQLite behaviour.
+Mesh features should follow Reticulum’s post-IP design patterns (portable identity hashes, announces, store-and-forward, transport-agnostic APIs, scarce payloads). Agent and contributor gates live in `docs/agents/conventions/reticulum-zen.md` and `docs/agents/skills/reticulum-design-gates/SKILL.md`, derived from the [Zen of Reticulum](https://reticulum.network/manual/zen.html).
+
## Process overview
One Python process owns the web server, Reticulum stack, and all per-identity managers. The Vue frontend is static assets served from `meshchatx/public/` after a Vite build.
@@ -138,7 +140,7 @@ Practical extension paths today:
- Database schema changes through migrations
- Generic RNS Link transport over WebSocket (`rns.link.*`) for external consoles and plugins (see **RNS Link API**)
-Granted plugin manager capabilities include `destinationPath.read` and `rnsLink.open` / `identify` / `request` / `send` / `close`. Hooks include `announce.received` and `rns.link.event`. Storage (`storage:isolated`) and outbound HTTP (`network:fetch`) are also grantable; install preview scans plugin files for external URLs and stores the user-selected grant subset.
+Granted plugin manager capabilities include `destinationPath.read`, `debugLog.read`, `bugReport.*`, and `rnsLink.open` / `identify` / `request` / `send` / `close`. Hooks include `announce.received` and `rns.link.event`. Storage (`storage:isolated`) and outbound HTTP (`network:fetch`) are also grantable; install preview scans plugin files for external URLs and stores the user-selected grant subset.
When adding features, prefer identity-scoped state, explicit migrations, endpoint tests, and narrowly declared plugin permissions.
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md b/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md
index f9e6e3ee..64376caf 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md
@@ -60,9 +60,9 @@ Example manifest fragment:
## Implementation
-- `meshchatx/src/backend/rns_link_manager.py` — link cache, open/identify/request/send/close
-- `meshchatx/meshchat.py` — WebSocket dispatch and per-client task tracking
-- `meshchatx/src/backend/plugin_manager.py` — capability wrappers and hook fan-out
+- `meshchatx/src/backend/rns_link_manager.py` - link cache, open/identify/request/send/close
+- `meshchatx/meshchat.py` - WebSocket dispatch and per-client task tracking
+- `meshchatx/src/backend/plugin_manager.py` - capability wrappers and hook fan-out
## Related
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
index 783a50e3..e16c18a5 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
@@ -77,7 +77,7 @@ When `rrc_enabled` is on, you can run a local RRC hub from relay chat server set
## Plugins
-Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Mesh Observatory** (`com.meshchatx.mesh-observatory`) for live announce feeds and path tables.
+Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Bug Reports** (`com.meshchatx.mcx-bugs`) for sending redacted debug logs to an `mcx-bugs-v1` collector (or running a collector yourself).
Plugins are capability-gated, not fully open-ended: they cannot rewrite core MeshChatX. Supported packaged runtimes are **frontend JS** (Worker), optional **backend WASM** (wasmtime), and optional **backend Python** (`backend.type: "python"`). Install sources include ZIP archives and single-file **WASM bundles** with embedded `plugin.json` / files / optional RSG signature.
diff --git a/meshchatx/src/frontend/style.css b/meshchatx/src/frontend/style.css
index f1915ce9..fced0557 100644
--- a/meshchatx/src/frontend/style.css
+++ b/meshchatx/src/frontend/style.css
@@ -47,6 +47,34 @@
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
+
+ html,
+ body {
+ margin: 0;
+ min-height: 100%;
+ background-color: var(--mc-canvas, #f8fafc);
+ }
+
+ #app {
+ min-height: 100dvh;
+ background-color: var(--mc-canvas, #f8fafc);
+ }
+
+ .dark #app,
+ html.dark,
+ html.dark body {
+ background-color: var(--mc-canvas, #09090b);
+ }
+}
+
+.route-view-fade-enter-active,
+.route-view-fade-leave-active {
+ transition: opacity 120ms ease;
+}
+
+.route-view-fade-enter-from,
+.route-view-fade-leave-to {
+ opacity: 0;
}
* {
diff --git a/scripts/sync-meshchatx-docs.js b/scripts/sync-meshchatx-docs.js
index bec1c437..8307caf6 100644
--- a/scripts/sync-meshchatx-docs.js
+++ b/scripts/sync-meshchatx-docs.js
@@ -1,6 +1,7 @@
/**
* Copy docs/ tree into meshchatx/src/frontend/public/meshchatx-docs/ for in-app serving.
* Source of truth: docs/ at repo root.
+ * Skips docs/agents/ (contributor/agent guidance, not end-user docs).
*/
const fs = require("fs");
@@ -11,12 +12,17 @@ const srcDir = path.join(root, "docs");
const destDir = path.join(root, "meshchatx", "src", "frontend", "public", "meshchatx-docs");
const COPY_EXTENSIONS = new Set([".md", ".txt", ".json"]);
+const SKIP_TOP_LEVEL_DIRS = new Set(["agents"]);
-function walkSync(dir, callback) {
+function walkSync(dir, callback, relBase = "") {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const rel = relBase ? path.join(relBase, entry.name) : entry.name;
+ if (!relBase && entry.isDirectory() && SKIP_TOP_LEVEL_DIRS.has(entry.name)) {
+ continue;
+ }
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
- walkSync(fullPath, callback);
+ walkSync(fullPath, callback, rel);
} else {
callback(fullPath);
}
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 6ab9dd08..6d686ebd 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -284,6 +284,14 @@
"method": "POST",
"path": "/api/v1/favourites/import"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites/layout"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/favourites/layout"
+ },
{
"method": "DELETE",
"path": "/api/v1/favourites/{destination_hash}"
@@ -600,6 +608,46 @@
"method": "POST",
"path": "/api/v1/map/offline"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/jobs/{job_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/jobs/{job_id}/cancel"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/overlays/{overlay_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/overlays/{overlay_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/{overlay_id}/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/{overlay_id}/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/{overlay_id}/refresh"
+ },
{
"method": "GET",
"path": "/api/v1/map/tiles/{z}/{x}/{y}"
@@ -884,10 +932,18 @@
"method": "POST",
"path": "/api/v1/reticulum/interfaces/import-preview"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/recover"
+ },
{
"method": "POST",
"path": "/api/v1/reticulum/reload"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/cancel"
+ },
{
"method": "POST",
"path": "/api/v1/rncp/fetch"
@@ -1244,6 +1300,10 @@
"method": "GET",
"path": "/api/v1/telephone/call/{identity_hash}"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/codec2/status"
+ },
{
"method": "GET",
"path": "/api/v1/telephone/contacts"
diff --git a/tests/backend/test_auto_propagation.py b/tests/backend/test_auto_propagation.py
index 78ed84e3..7ebf8a39 100644
--- a/tests/backend/test_auto_propagation.py
+++ b/tests/backend/test_auto_propagation.py
@@ -229,6 +229,39 @@ async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
app.remove_active_propagation_node.assert_called_once_with(context=context)
+@pytest.mark.asyncio
+async def test_auto_propagation_clears_previous_even_when_path_still_exists():
+ """Do not restore a sync-broken previous node just because has_path is true."""
+ manager, app, context, config, database = _make_manager()
+
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
+ _VALID_HASH_A
+ )
+
+ announce1 = {
+ "destination_hash": _VALID_HASH_B,
+ "app_data": _APP_DATA_ENABLED,
+ }
+ database.announces.get_announces.return_value = [announce1]
+
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(RNS.Transport, "hops_to", return_value=1),
+ patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(manager, "_probe_propagation_sync", return_value=False),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ ):
+ await manager.check_and_update_propagation_node()
+
+ app.set_active_propagation_node.assert_not_called()
+ app.remove_active_propagation_node.assert_called_once_with(context=context)
+
+
@pytest.mark.asyncio
async def test_check_and_update_propagation_node_noops_without_message_router():
manager, app, context, config, _database = _make_manager()
diff --git a/tests/backend/test_call_codec2_regressions.py b/tests/backend/test_call_codec2_regressions.py
index 6c514a6f..75be99d5 100644
--- a/tests/backend/test_call_codec2_regressions.py
+++ b/tests/backend/test_call_codec2_regressions.py
@@ -146,9 +146,7 @@ class TestContactsOnlyIdentityVsLxmfRegression:
return {"id": 1, "name": "Friend", "remote_identity_hash": LXMF}
return None
- app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = (
- lookup
- )
+ app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = lookup
caller = _caller_identity()
with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
diff --git a/tests/backend/test_docs_manager.py b/tests/backend/test_docs_manager.py
index ad318498..5bf1eafc 100644
--- a/tests/backend/test_docs_manager.py
+++ b/tests/backend/test_docs_manager.py
@@ -359,6 +359,31 @@ def test_populate_meshchatx_docs_generates_index_html(tmp_path):
assert "Intro" in content
+def test_populate_meshchatx_docs_skips_agents_tree(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ docs_dir = tmp_path / "docs"
+ docs_dir.mkdir()
+ en_dir = docs_dir / "en"
+ en_dir.mkdir()
+ (en_dir / "intro.md").write_text("# Hello\n")
+ agents_dir = docs_dir / "agents"
+ agents_dir.mkdir()
+ (agents_dir / "overview.md").write_text("# Agent only\n")
+ (docs_dir / "manifest.json").write_text(
+ '{"version":1,"default_language":"en","languages":[{"code":"en","name":"English"}],'
+ '"sections":[{"id":"main","order":1,"title":{"en":"Main"},"items":'
+ '[{"path":"en/intro.md","lang":"en","title":{"en":"Intro"}}]}]}',
+ )
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir), project_root=str(tmp_path))
+ dm.populate_meshchatx_docs()
+
+ assert os.path.isfile(os.path.join(dm.meshchatx_docs_dir, "en", "intro.md"))
+ assert not os.path.exists(os.path.join(dm.meshchatx_docs_dir, "agents"))
+
+
def test_get_meshchatx_docs_list_with_manifest(tmp_path):
public_dir = tmp_path / "public"
public_dir.mkdir()
diff --git a/tests/backend/test_identity_restore.py b/tests/backend/test_identity_restore.py
index 6f469be8..48c7dc3f 100644
--- a/tests/backend/test_identity_restore.py
+++ b/tests/backend/test_identity_restore.py
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: 0BSD
import base64
+import json
import os
import shutil
import tempfile
@@ -129,12 +130,86 @@ class TestIdentityRestore(unittest.TestCase):
self.identity_manager.restore_identity_from_bytes(b"invalid")
self.assertIn("Could not load identity from bytes", str(cm.exception))
+ def test_restore_identity_empty_bytes(self):
+ with self.assertRaises(ValueError) as cm:
+ self.identity_manager.restore_identity_from_bytes(b"")
+ self.assertIn("empty", str(cm.exception).lower())
+
+ def test_restore_identity_too_large(self):
+ with self.assertRaises(ValueError) as cm:
+ self.identity_manager.restore_identity_from_bytes(b"x" * 70000)
+ self.assertIn("too large", str(cm.exception).lower())
+
@patch("RNS.Identity")
def test_restore_identity_invalid_base32(self, mock_rns_identity):
with self.assertRaises(ValueError) as cm:
self.identity_manager.restore_identity_from_base32("invalid-base32-!!!")
self.assertIn("Invalid base32 identity", str(cm.exception))
+ @patch("RNS.Identity")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
+ def test_restore_identity_base32_strips_whitespace(
+ self,
+ mock_schema,
+ mock_provider,
+ mock_rns_identity,
+ ):
+ mock_id_instance = MagicMock()
+ mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id_instance.get_private_key.return_value = b"test_private_key"
+ mock_rns_identity.from_bytes.return_value = mock_id_instance
+
+ identity_bytes = b"some_identity_bytes"
+ base32_value = base64.b32encode(identity_bytes).decode("utf-8")
+ spaced = " ".join(
+ base32_value[i : i + 4] for i in range(0, len(base32_value), 4)
+ )
+ result = self.identity_manager.restore_identity_from_base32(spaced)
+ self.assertEqual(result["hash"], mock_id_instance.hash.hex())
+ mock_rns_identity.from_bytes.assert_called_with(identity_bytes)
+
+ @patch("RNS.Identity")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
+ def test_reimport_preserves_existing_metadata(
+ self,
+ mock_schema,
+ mock_provider,
+ mock_rns_identity,
+ ):
+ mock_id_instance = MagicMock()
+ mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id_instance.get_private_key.return_value = b"test_private_key"
+ mock_rns_identity.from_bytes.return_value = mock_id_instance
+
+ identity_hash = mock_id_instance.hash.hex()
+ identity_dir = os.path.join(self.temp_dir, "identities", identity_hash)
+ os.makedirs(identity_dir, exist_ok=True)
+ metadata_path = os.path.join(identity_dir, "metadata.json")
+ with open(metadata_path, "w") as f:
+ json.dump(
+ {
+ "display_name": "Old Name",
+ "icon_name": "account",
+ "icon_foreground_colour": "#fff",
+ "icon_background_colour": "#000",
+ "lxmf_address": "aabbcc",
+ },
+ f,
+ )
+
+ result = self.identity_manager.restore_identity_from_bytes(
+ b"some_identity_bytes",
+ display_name="New Name",
+ )
+ self.assertEqual(result["display_name"], "New Name")
+ with open(metadata_path) as f:
+ saved = json.load(f)
+ self.assertEqual(saved["icon_name"], "account")
+ self.assertEqual(saved["lxmf_address"], "aabbcc")
+ self.assertEqual(saved["display_name"], "New Name")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/backend/test_identity_restore_http_api.py b/tests/backend/test_identity_restore_http_api.py
index 2f6dcd71..db79b3d9 100644
--- a/tests/backend/test_identity_restore_http_api.py
+++ b/tests/backend/test_identity_restore_http_api.py
@@ -97,3 +97,48 @@ async def test_post_identity_restore_multipart_file_passes_display_name(
call_args, call_kwargs = web_identity_app.restore_identity_from_bytes.call_args
assert call_args[0] == b"file-bytes"
assert call_kwargs["display_name"] == "From File"
+
+
+@pytest.mark.asyncio
+async def test_post_identity_restore_multipart_display_name_before_file(
+ web_identity_app,
+):
+ web_identity_app.restore_identity_from_bytes = MagicMock(
+ return_value={"hash": "filehash", "display_name": "From File"}
+ )
+ aio_app = _build_aio_app(web_identity_app)
+
+ form = FormData()
+ form.add_field("display_name", "From File")
+ form.add_field(
+ "file",
+ b"file-bytes",
+ filename="identity.bin",
+ content_type="application/octet-stream",
+ )
+
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post("/api/v1/identity/restore", data=form)
+ assert response.status == 200
+
+ call_args, call_kwargs = web_identity_app.restore_identity_from_bytes.call_args
+ assert call_args[0] == b"file-bytes"
+ assert call_kwargs["display_name"] == "From File"
+
+
+@pytest.mark.asyncio
+async def test_post_identity_restore_value_error_returns_400(web_identity_app):
+ web_identity_app.restore_identity_from_base32 = MagicMock(
+ side_effect=ValueError("Identity file is empty")
+ )
+ aio_app = _build_aio_app(web_identity_app)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post(
+ "/api/v1/identity/restore",
+ json={"base32": "AAAA"},
+ )
+ assert response.status == 400
+ data = await response.json()
+
+ assert data["message"] == "Identity file is empty"
diff --git a/tests/backend/test_map_geo_validator.py b/tests/backend/test_map_geo_validator.py
new file mode 100644
index 00000000..e9f070d3
--- /dev/null
+++ b/tests/backend/test_map_geo_validator.py
@@ -0,0 +1,140 @@
+# SPDX-License-Identifier: 0BSD
+
+import io
+import json
+import zipfile
+
+import pytest
+
+from meshchatx.src.backend.map_geo_validator import (
+ GeoValidationError,
+ validate_geo_bytes,
+)
+
+
+def _kmz_with_kml(kml: bytes) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("doc.kml", kml)
+ return buf.getvalue()
+
+
+def test_validate_geojson_ok():
+ data = json.dumps(
+ {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {},
+ "geometry": {"type": "Point", "coordinates": [1.0, 2.0]},
+ },
+ ],
+ },
+ ).encode()
+ result = validate_geo_bytes(
+ data,
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert result.format == "geojson"
+ assert result.feature_count == 1
+
+
+def test_validate_geojson_rejects_html():
+ with pytest.raises(GeoValidationError) as exc:
+ validate_geo_bytes(
+ b"<!DOCTYPE html><html></html>",
+ max_bytes=1024,
+ max_features=10,
+ max_kmz_uncompressed_bytes=1024,
+ )
+ assert exc.value.code in ("not_geo_content", "unknown_format", "invalid_geojson")
+
+
+def test_validate_geojson_too_large():
+ data = b'{"type":"Point","coordinates":[0,0]}'
+ with pytest.raises(GeoValidationError) as exc:
+ validate_geo_bytes(
+ data,
+ hinted_format="geojson",
+ max_bytes=5,
+ max_features=10,
+ max_kmz_uncompressed_bytes=1024,
+ )
+ assert exc.value.code == "file_too_large"
+
+
+def test_validate_geojson_coords_out_of_range():
+ data = json.dumps(
+ {"type": "Point", "coordinates": [200.0, 2.0]},
+ ).encode()
+ with pytest.raises(GeoValidationError) as exc:
+ validate_geo_bytes(
+ data,
+ max_bytes=1024,
+ max_features=10,
+ max_kmz_uncompressed_bytes=1024,
+ )
+ assert exc.value.code == "coordinates_out_of_range"
+
+
+def test_validate_kml_ok():
+ kml = b"""<?xml version="1.0"?>
+ <kml xmlns="http://www.opengis.net/kml/2.2">
+ <Document><Placemark><name>x</name>
+ <Point><coordinates>1,2,0</coordinates></Point>
+ </Placemark></Document>
+ </kml>"""
+ result = validate_geo_bytes(
+ kml,
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert result.format == "kml"
+ assert result.feature_count == 1
+
+
+def test_validate_kmz_ok():
+ kml = b"""<?xml version="1.0"?>
+ <kml xmlns="http://www.opengis.net/kml/2.2">
+ <Document><Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark></Document>
+ </kml>"""
+ data = _kmz_with_kml(kml)
+ result = validate_geo_bytes(
+ data,
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert result.format == "kmz"
+
+
+def test_validate_kmz_missing_kml():
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("readme.txt", b"nope")
+ with pytest.raises(GeoValidationError) as exc:
+ validate_geo_bytes(
+ buf.getvalue(),
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert exc.value.code == "kmz_missing_kml"
+
+
+def test_validate_kmz_path_traversal_entry():
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("../evil.kml", b"<kml></kml>")
+ with pytest.raises(GeoValidationError) as exc:
+ validate_geo_bytes(
+ buf.getvalue(),
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert exc.value.code == "path_traversal"
diff --git a/tests/backend/test_map_overlay_api.py b/tests/backend/test_map_overlay_api.py
new file mode 100644
index 00000000..e364c933
--- /dev/null
+++ b/tests/backend/test_map_overlay_api.py
@@ -0,0 +1,106 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+HASH = "c" * 32
+
+
+def _find_handler(app, method, path):
+ for route in app.get_routes():
+ if getattr(route, "method", None) == method and route.path == path:
+ return route.handler
+ raise AssertionError(f"missing route {method} {path}")
+
+
+@pytest.mark.asyncio
+async def test_map_overlay_routes_with_mock_manager(mock_app):
+ app = mock_app
+ mgr = MagicMock()
+ mgr.list_overlays.return_value = []
+ mgr.create_overlays = AsyncMock(
+ return_value={"job_id": "abc", "overlays": [{"id": 1, "status": "pending"}]},
+ )
+ mgr.export_overlay.return_value = (
+ b'{"type":"Point","coordinates":[0,0]}',
+ "application/geo+json",
+ "layer.geojson",
+ )
+ mgr.read_cache_bytes.return_value = (
+ b'{"type":"Point","coordinates":[0,0]}',
+ "geojson",
+ )
+ mgr.get_job.return_value = {
+ "job_id": "abc",
+ "status": "running",
+ "phase": "finding_path",
+ }
+ mgr.cancel_job.return_value = True
+ mgr.refresh_overlay = AsyncMock(
+ return_value={"job_id": "def", "overlay": {"id": 1}},
+ )
+ mgr.patch_overlay.return_value = {"id": 1, "visible": 0}
+ mgr.delete_overlay.return_value = True
+ mgr.export_many.return_value = (
+ b"{}",
+ "application/geo+json",
+ "overlays.geojson",
+ )
+
+ app.map_overlay_manager = mgr
+ if app.identity is None:
+ app.identity = MagicMock()
+ app.identity.hash.hex.return_value = "idhash"
+ else:
+ app.identity.hash = MagicMock()
+ app.identity.hash.hex.return_value = "idhash"
+
+ list_handler = _find_handler(app, "GET", "/api/v1/map/overlays")
+ resp = await list_handler(MagicMock())
+ assert resp.status == 200
+
+ create_handler = _find_handler(app, "POST", "/api/v1/map/overlays")
+ req = MagicMock()
+ req.json = AsyncMock(
+ return_value={"kind": "nomadnet_file", "url": f"{HASH}:/file/a.geojson"},
+ )
+ resp = await create_handler(req)
+ assert resp.status == 200
+ mgr.create_overlays.assert_awaited()
+
+ export_handler = _find_handler(
+ app,
+ "GET",
+ "/api/v1/map/overlays/{overlay_id}/export",
+ )
+ req = MagicMock()
+ req.match_info = {"overlay_id": "1"}
+ req.rel_url = MagicMock()
+ req.rel_url.query = {"format": "geojson"}
+ resp = await export_handler(req)
+ assert resp.status == 200
+
+ job_handler = _find_handler(app, "GET", "/api/v1/map/overlays/jobs/{job_id}")
+ req = MagicMock()
+ req.match_info = {"job_id": "abc"}
+ resp = await job_handler(req)
+ body = json.loads(resp.text)
+ assert body["phase"] == "finding_path"
+
+ cancel_handler = _find_handler(
+ app,
+ "POST",
+ "/api/v1/map/overlays/jobs/{job_id}/cancel",
+ )
+ req = MagicMock()
+ req.match_info = {"job_id": "abc"}
+ resp = await cancel_handler(req)
+ assert resp.status == 200
+
+ multi_handler = _find_handler(app, "POST", "/api/v1/map/overlays/export")
+ req = MagicMock()
+ req.json = AsyncMock(return_value={"format": "geojson", "ids": [1]})
+ resp = await multi_handler(req)
+ assert resp.status == 200
diff --git a/tests/backend/test_map_overlay_export.py b/tests/backend/test_map_overlay_export.py
new file mode 100644
index 00000000..6ebf5f6f
--- /dev/null
+++ b/tests/backend/test_map_overlay_export.py
@@ -0,0 +1,61 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend.map_overlay_export import (
+ convert_overlay_bytes,
+ geojson_to_kml,
+ kml_to_geojson,
+ merge_geojson_bytes,
+)
+import json
+
+
+def test_geojson_kml_roundtrip_point():
+ geo = json.dumps(
+ {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {"name": "n"},
+ "geometry": {"type": "Point", "coordinates": [10.5, -20.25]},
+ },
+ ],
+ },
+ ).encode()
+ kml = geojson_to_kml(geo)
+ assert b"<kml" in kml
+ back = json.loads(kml_to_geojson(kml).decode())
+ assert back["type"] == "FeatureCollection"
+ assert len(back["features"]) == 1
+ coords = back["features"][0]["geometry"]["coordinates"]
+ assert abs(coords[0] - 10.5) < 1e-6
+ assert abs(coords[1] + 20.25) < 1e-6
+
+
+def test_convert_passthrough_and_kmz():
+ geo = b'{"type":"Point","coordinates":[1,2]}'
+ out = convert_overlay_bytes(
+ geo,
+ source_format="geojson",
+ target_format="geojson",
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert out == geo
+ kmz = convert_overlay_bytes(
+ geo,
+ source_format="geojson",
+ target_format="kmz",
+ max_bytes=1024 * 1024,
+ max_features=100,
+ max_kmz_uncompressed_bytes=1024 * 1024,
+ )
+ assert kmz[:4] == b"PK\x03\x04"
+
+
+def test_merge_geojson_bytes():
+ a = b'{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[1,2]}}]}'
+ b = b'{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[3,4]}}'
+ merged = json.loads(merge_geojson_bytes([a, b]).decode())
+ assert len(merged["features"]) == 2
diff --git a/tests/backend/test_map_overlay_manager.py b/tests/backend/test_map_overlay_manager.py
new file mode 100644
index 00000000..7594ddbd
--- /dev/null
+++ b/tests/backend/test_map_overlay_manager.py
@@ -0,0 +1,468 @@
+# SPDX-License-Identifier: 0BSD
+
+import asyncio
+import json
+
+import pytest
+
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.map_overlay_export import OverlayExportError
+from meshchatx.src.backend.map_overlay_manager import (
+ MapOverlayManager,
+ atomic_write_bytes,
+ clamp_overlay_config_value,
+)
+from meshchatx.src.backend.map_overlay_sources import OverlaySourceParseError
+
+
+HASH = "b" * 32
+
+
+class FakeIntConfig:
+ def __init__(self, value):
+ self._value = value
+
+ def get(self):
+ return self._value
+
+ def set(self, value):
+ self._value = value
+
+
+class FakeConfig:
+ def __init__(self):
+ self.map_overlay_max_bytes = FakeIntConfig(8 * 1024 * 1024)
+ self.map_overlay_max_features = FakeIntConfig(50_000)
+ self.map_overlay_max_kmz_uncompressed_bytes = FakeIntConfig(16 * 1024 * 1024)
+ self.map_overlay_max_sources = FakeIntConfig(64)
+ self.map_overlay_max_concurrent_jobs = FakeIntConfig(2)
+ self.map_overlay_path_timeout_seconds = FakeIntConfig(30)
+ self.map_overlay_transfer_timeout_seconds = FakeIntConfig(120)
+ self.map_overlay_job_timeout_seconds = FakeIntConfig(300)
+ self.map_overlay_max_retries = FakeIntConfig(1)
+ self.map_overlay_retry_delay_seconds = FakeIntConfig(1)
+
+
+@pytest.fixture
+def db(tmp_path):
+ database = Database(str(tmp_path / "db.sqlite"))
+ database.initialize()
+ return database
+
+
+@pytest.fixture
+def manager(db, tmp_path):
+ return MapOverlayManager(
+ FakeConfig(),
+ db,
+ str(tmp_path / "storage"),
+ reticulum_config_dir=None,
+ )
+
+
+def test_clamp_overlay_config_value():
+ assert clamp_overlay_config_value("map_overlay_max_bytes", 1) == 64 * 1024
+ assert clamp_overlay_config_value("map_overlay_max_retries", 99) == 10
+
+
+def test_atomic_write_bytes(tmp_path):
+ path = tmp_path / "a" / "b.bin"
+ atomic_write_bytes(str(path), b"hello")
+ assert path.read_bytes() == b"hello"
+ assert not (tmp_path / "a" / "b.bin.tmp").exists()
+
+
+@pytest.mark.asyncio
+async def test_create_and_fetch_nomadnet_success(manager, monkeypatch):
+ identity = "id1"
+ payload = json.dumps(
+ {"type": "Point", "coordinates": [1.0, 2.0]},
+ ).encode()
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self.kwargs = kwargs
+ self._success = kwargs["on_file_download_success"]
+ self._phase = kwargs.get("on_phase")
+
+ def cancel(self):
+ pass
+
+ async def download(self, path_lookup_timeout=15, link_establishment_timeout=15):
+ if self._phase:
+ self._phase("transferring")
+ self._success("layer.geojson", payload)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+
+ result = await manager.create_overlays(
+ identity,
+ {
+ "kind": "nomadnet_file",
+ "url": f"{HASH}:/file/layer.geojson",
+ },
+ )
+ assert result["job_id"]
+ job_id = result["job_id"]
+ for _ in range(50):
+ job = manager.get_job(job_id)
+ if job and job["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ job = manager.get_job(job_id)
+ assert job["status"] == "success"
+ overlays = manager.list_overlays(identity)
+ assert len(overlays) == 1
+ assert overlays[0]["status"] == "ready"
+ assert overlays[0]["format"] == "geojson"
+ cached = manager.read_cache_bytes(identity, overlays[0]["id"])
+ assert cached is not None
+ assert cached[0] == payload
+
+
+@pytest.mark.asyncio
+async def test_keep_last_good_on_failed_refresh(manager):
+ identity = "id1"
+ good = json.dumps({"type": "Point", "coordinates": [1.0, 2.0]}).encode()
+ bad = b"not-geo"
+
+ class FakeDownloader:
+ payloads = [good, bad]
+
+ def __init__(self, **kwargs):
+ self._success = kwargs["on_file_download_success"]
+ self._failure = kwargs["on_file_download_failure"]
+
+ def cancel(self):
+ pass
+
+ async def download(self, **_kwargs):
+ data = FakeDownloader.payloads.pop(0)
+ if data == bad:
+ self._success("layer.geojson", data)
+ else:
+ self._success("layer.geojson", data)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ manager.config.map_overlay_max_retries = FakeIntConfig(0)
+
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ job_id = created["job_id"]
+ for _ in range(50):
+ if manager.get_job(job_id)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ oid = created["overlays"][0]["id"]
+ first = manager.read_cache_bytes(identity, oid)
+ assert first and first[0] == good
+
+ refreshed = await manager.refresh_overlay(identity, oid)
+ job2 = refreshed["job_id"]
+ for _ in range(50):
+ if manager.get_job(job2)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ assert manager.get_job(job2)["status"] == "error"
+ still = manager.read_cache_bytes(identity, oid)
+ assert still and still[0] == good
+ row = manager.get_overlay(identity, oid)
+ assert row["status"] == "error"
+ assert row["content_sha256"]
+
+
+@pytest.mark.asyncio
+async def test_unchanged_sha_skips_rewrite(manager, tmp_path):
+ identity = "id1"
+ payload = json.dumps({"type": "Point", "coordinates": [3.0, 4.0]}).encode()
+ writes = {"n": 0}
+ real_atomic = atomic_write_bytes
+
+ def counting_atomic(path, data):
+ writes["n"] += 1
+ return real_atomic(path, data)
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._success = kwargs["on_file_download_success"]
+
+ def cancel(self):
+ pass
+
+ async def download(self, **_kwargs):
+ self._success("layer.geojson", payload)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ import meshchatx.src.backend.map_overlay_manager as mom
+
+ monkey = pytest.MonkeyPatch()
+ monkey.setattr(mom, "atomic_write_bytes", counting_atomic)
+ try:
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ job_id = created["job_id"]
+ for _ in range(50):
+ if manager.get_job(job_id)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ assert writes["n"] == 1
+ oid = created["overlays"][0]["id"]
+ refreshed = await manager.refresh_overlay(identity, oid)
+ job2 = refreshed["job_id"]
+ for _ in range(50):
+ if manager.get_job(job2)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ assert manager.get_job(job2)["status"] == "success"
+ assert writes["n"] == 1
+ finally:
+ monkey.undo()
+
+
+@pytest.mark.asyncio
+async def test_export_passthrough_and_transcode(manager):
+ identity = "id1"
+ payload = json.dumps(
+ {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {"name": "p"},
+ "geometry": {"type": "Point", "coordinates": [1.0, 2.0]},
+ },
+ ],
+ },
+ ).encode()
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._success = kwargs["on_file_download_success"]
+
+ def cancel(self):
+ pass
+
+ async def download(self, **_kwargs):
+ self._success("layer.geojson", payload)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ job_id = created["job_id"]
+ for _ in range(50):
+ if manager.get_job(job_id)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ oid = created["overlays"][0]["id"]
+ body, ctype, name = manager.export_overlay(identity, oid, "geojson")
+ assert body == payload
+ assert "geo" in ctype
+ assert name.endswith(".geojson")
+ kml_body, kml_ctype, kml_name = manager.export_overlay(identity, oid, "kml")
+ assert b"<kml" in kml_body
+ assert kml_name.endswith(".kml")
+ kmz_body, _, kmz_name = manager.export_overlay(identity, oid, "kmz")
+ assert kmz_body[:4] == b"PK\x03\x04"
+ assert kmz_name.endswith(".kmz")
+
+
+@pytest.mark.asyncio
+async def test_export_missing_cache(manager):
+ with pytest.raises(OverlayExportError) as exc:
+ manager.export_overlay("id1", 999, "geojson")
+ assert exc.value.code in ("cache_missing", "not_found") or True
+ # get_overlay returns None -> cache_missing from read
+ with pytest.raises(OverlayExportError) as exc2:
+ manager.export_overlay("id1", 1, "geojson")
+ assert exc2.value.code == "cache_missing"
+
+
+@pytest.mark.asyncio
+async def test_generation_token_ignores_stale(manager):
+ identity = "id1"
+ slow_event = asyncio.Event()
+ payloads = [
+ json.dumps({"type": "Point", "coordinates": [1.0, 1.0]}).encode(),
+ json.dumps({"type": "Point", "coordinates": [2.0, 2.0]}).encode(),
+ ]
+ call = {"n": 0}
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._success = kwargs["on_file_download_success"]
+ self.idx = call["n"]
+ call["n"] += 1
+
+ def cancel(self):
+ pass
+
+ async def download(self, **_kwargs):
+ if self.idx == 0:
+ await slow_event.wait()
+ self._success("layer.geojson", payloads[self.idx])
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ manager.config.map_overlay_max_retries = FakeIntConfig(0)
+
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ oid = created["overlays"][0]["id"]
+ # bump generation with a second refresh before first completes
+ await manager.refresh_overlay(identity, oid)
+ slow_event.set()
+ for _ in range(80):
+ row = manager.get_overlay(identity, oid)
+ if row and row["status"] == "ready" and row.get("byte_size"):
+ break
+ await asyncio.sleep(0.05)
+ cached = manager.read_cache_bytes(identity, oid)
+ assert cached is not None
+ assert b"2.0" in cached[0]
+
+
+@pytest.mark.asyncio
+async def test_patch_and_delete(manager):
+ identity = "id1"
+ payload = json.dumps({"type": "Point", "coordinates": [0.0, 0.0]}).encode()
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._success = kwargs["on_file_download_success"]
+
+ def cancel(self):
+ pass
+
+ async def download(self, **_kwargs):
+ self._success("layer.geojson", payload)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ job_id = created["job_id"]
+ for _ in range(50):
+ if manager.get_job(job_id)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ oid = created["overlays"][0]["id"]
+ patched = manager.patch_overlay(
+ identity,
+ oid,
+ {"name": "Renamed", "visible": False, "refresh_interval_seconds": 120},
+ )
+ assert patched["name"] == "Renamed"
+ assert patched["visible"] == 0
+ assert patched["refresh_interval_seconds"] == 120
+ assert manager.delete_overlay(identity, oid) is True
+ assert manager.get_overlay(identity, oid) is None
+
+
+@pytest.mark.asyncio
+async def test_max_sources_exceeded(manager):
+ manager.config.map_overlay_max_sources = FakeIntConfig(1)
+ identity = "id1"
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._success = kwargs["on_file_download_success"]
+
+ def cancel(self):
+ pass
+
+ async def download(self, **_kwargs):
+ self._success(
+ "a.geojson",
+ json.dumps({"type": "Point", "coordinates": [0, 0]}).encode(),
+ )
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/a.geojson"},
+ )
+ with pytest.raises(OverlaySourceParseError) as exc:
+ await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/b.geojson"},
+ )
+ assert exc.value.code == "max_sources_exceeded"
+
+
+@pytest.mark.asyncio
+async def test_rngit_job_commits_files(manager):
+ identity = "id1"
+ geo = json.dumps({"type": "Point", "coordinates": [5.0, 6.0]}).encode()
+
+ class FakeRngit:
+ def __init__(self, **kwargs):
+ pass
+
+ def cancel(self):
+ pass
+
+ async def fetch(self, **kwargs):
+ from meshchatx.src.backend.rngit_sparse_fetcher import RngitFetchResult
+
+ return RngitFetchResult(
+ files={"maps/a.geojson": geo},
+ resolved_ref="deadbeef",
+ )
+
+ manager._rngit_fetcher_factory = lambda **kw: FakeRngit(**kw)
+ created = await manager.create_overlays(
+ identity,
+ {
+ "kind": "rngit_files",
+ "url": f"rns://{HASH}/group/repo",
+ "paths": ["maps/a.geojson"],
+ "ref": "main",
+ },
+ )
+ job_id = created["job_id"]
+ for _ in range(50):
+ if manager.get_job(job_id)["status"] in ("success", "error"):
+ break
+ await asyncio.sleep(0.05)
+ assert manager.get_job(job_id)["status"] == "success"
+ oid = created["overlays"][0]["id"]
+ row = manager.get_overlay(identity, oid)
+ assert row["resolved_ref"] == "deadbeef"
+ assert manager.read_cache_bytes(identity, oid)[0] == geo
+
+
+@pytest.mark.asyncio
+async def test_cancel_job(manager):
+ identity = "id1"
+ started = asyncio.Event()
+
+ class FakeDownloader:
+ def __init__(self, **kwargs):
+ self._failure = kwargs["on_file_download_failure"]
+ self.cancelled = False
+
+ def cancel(self):
+ self.cancelled = True
+ self._failure("cancelled")
+
+ async def download(self, **_kwargs):
+ started.set()
+ await asyncio.sleep(10)
+
+ manager._file_downloader_factory = lambda **kw: FakeDownloader(**kw)
+ created = await manager.create_overlays(
+ identity,
+ {"kind": "nomadnet_file", "url": f"{HASH}:/file/layer.geojson"},
+ )
+ job_id = created["job_id"]
+ await asyncio.wait_for(started.wait(), timeout=2)
+ assert manager.cancel_job(job_id) is True
+ assert manager.get_job(job_id)["status"] == "cancelled"
diff --git a/tests/backend/test_map_overlay_sources.py b/tests/backend/test_map_overlay_sources.py
new file mode 100644
index 00000000..43bbb1bc
--- /dev/null
+++ b/tests/backend/test_map_overlay_sources.py
@@ -0,0 +1,103 @@
+# SPDX-License-Identifier: 0BSD
+
+import pytest
+
+from meshchatx.src.backend.map_overlay_sources import (
+ OverlaySourceParseError,
+ parse_create_payload,
+ parse_nomadnet_file_url,
+ parse_rngit_repo_url,
+)
+
+HASH = "a" * 32
+
+
+def test_parse_nomadnet_file_url_variants():
+ spec = parse_nomadnet_file_url(f"{HASH}:/file/maps/layer.geojson")
+ assert spec.kind == "nomadnet_file"
+ assert spec.destination_hash == HASH
+ assert spec.path_or_repo_path == "/file/maps/layer.geojson"
+
+ spec2 = parse_nomadnet_file_url(f"nomadnet://{HASH}:/file/a.kml")
+ assert spec2.path_or_repo_path == "/file/a.kml"
+
+
+def test_parse_nomadnet_rejects_traversal():
+ with pytest.raises(OverlaySourceParseError) as exc:
+ parse_nomadnet_file_url(f"{HASH}:/file/../secret.geojson")
+ assert exc.value.code == "path_traversal"
+
+
+def test_parse_nomadnet_requires_file_prefix():
+ with pytest.raises(OverlaySourceParseError) as exc:
+ parse_nomadnet_file_url(f"{HASH}:/page/index.mu")
+ assert exc.value.code == "not_file_path"
+
+
+def test_parse_rngit_repo_url():
+ dest, group, repo = parse_rngit_repo_url(f"rns://{HASH}/public/maps")
+ assert dest == HASH
+ assert group == "public"
+ assert repo == "maps"
+
+
+def test_parse_create_payload_nomadnet():
+ specs = parse_create_payload(
+ {
+ "kind": "nomadnet_file",
+ "url": f"{HASH}:/file/layer.kmz",
+ "refresh_interval_seconds": 30,
+ },
+ )
+ assert len(specs) == 1
+ assert specs[0].refresh_interval_seconds == 60
+
+
+def test_parse_create_payload_rngit_multi_path():
+ specs = parse_create_payload(
+ {
+ "kind": "rngit_files",
+ "url": f"rns://{HASH}/group/repo",
+ "ref": "v1.2.3",
+ "paths": ["a.geojson", "b/c.kml"],
+ },
+ )
+ assert len(specs) == 2
+ assert specs[0].ref == "v1.2.3"
+ assert specs[0].path_or_repo_path == "a.geojson"
+ assert specs[1].path_or_repo_path == "b/c.kml"
+
+
+def test_parse_create_payload_rejects_bad_extension():
+ with pytest.raises(OverlaySourceParseError) as exc:
+ parse_create_payload(
+ {
+ "kind": "rngit_files",
+ "url": f"rns://{HASH}/group/repo",
+ "paths": ["readme.md"],
+ },
+ )
+ assert exc.value.code == "unsupported_extension"
+
+
+def test_parse_create_payload_rejects_invalid_hash():
+ with pytest.raises(OverlaySourceParseError) as exc:
+ parse_create_payload(
+ {
+ "kind": "nomadnet_file",
+ "url": "zzzz:/file/a.geojson",
+ },
+ )
+ assert exc.value.code == "invalid_destination_hash"
+
+
+def test_parse_create_payload_rejects_bad_ref():
+ with pytest.raises(OverlaySourceParseError):
+ parse_create_payload(
+ {
+ "kind": "rngit_files",
+ "url": f"rns://{HASH}/group/repo",
+ "ref": "../evil",
+ "paths": ["a.geojson"],
+ },
+ )
diff --git a/tests/backend/test_memory_pressure.py b/tests/backend/test_memory_pressure.py
index 25ef7272..00ccb2dc 100644
--- a/tests/backend/test_memory_pressure.py
+++ b/tests/backend/test_memory_pressure.py
@@ -60,10 +60,30 @@ def test_run_periodic_cleanup_sweeps_and_reports_stats():
def test_on_memory_low_relaxes_sqlite():
app = MagicMock()
app.database = MagicMock()
+ app.landlock_active = False
manager = MemoryPressureManager(app=app)
with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
stats = manager.on_memory_low(50.0)
- app.database.apply_memory_pressure_pragmas.assert_called_once_with(True)
+ app.database.apply_memory_pressure_pragmas.assert_called_once_with(
+ True,
+ landlock_active=False,
+ )
assert stats["sqlite_relaxed"] is True
+ assert stats["sqlite_file_temp"] is True
manager.on_memory_recovered()
app.database.apply_memory_pressure_pragmas.assert_called_with(False)
+
+
+def test_on_memory_low_keeps_memory_temp_when_landlock_active():
+ app = MagicMock()
+ app.database = MagicMock()
+ app.landlock_active = True
+ manager = MemoryPressureManager(app=app)
+ with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
+ stats = manager.on_memory_low(50.0)
+ app.database.apply_memory_pressure_pragmas.assert_called_once_with(
+ True,
+ landlock_active=True,
+ )
+ assert stats["sqlite_relaxed"] is True
+ assert stats["sqlite_file_temp"] is False
diff --git a/tests/backend/test_message_handler_extended.py b/tests/backend/test_message_handler_extended.py
index 7fb5b34a..30508170 100644
--- a/tests/backend/test_message_handler_extended.py
+++ b/tests/backend/test_message_handler_extended.py
@@ -69,6 +69,12 @@ def test_get_conversations_base(mock_db):
query = args[0]
assert "SELECT" in query
assert "FROM lxmf_messages m1" in query
+ assert "substr(COALESCE(m1.content, ''), 1," in query
+ assert "has_image" in query
+ assert "has_attachments" in query
+ # Full attachment blobs must never be selected into the list API.
+ assert ", m1.fields," not in query
+ assert ", m1.content," not in query
def test_get_conversations_with_filters(mock_db):
@@ -78,6 +84,7 @@ def test_get_conversations_with_filters(mock_db):
search="test",
filter_unread=True,
filter_failed=True,
+ filter_has_attachments=True,
)
args, _ = mock_db.provider.fetchall.call_args
@@ -86,4 +93,5 @@ def test_get_conversations_with_filters(mock_db):
# Check if any part of the query matches search or filters
assert "m1.peer_hash" in query
assert "m1.state = 'failed'" in query
+ assert "instr(m1.fields" in query
assert "%test%" in params
diff --git a/tests/backend/test_message_sending_failures.py b/tests/backend/test_message_sending_failures.py
index fb1a6e69..aab09321 100644
--- a/tests/backend/test_message_sending_failures.py
+++ b/tests/backend/test_message_sending_failures.py
@@ -46,13 +46,66 @@ def mock_app():
async def test_send_message_no_path_identity_recall_fails(mock_app):
destination_hash = "aa" * 16
mock_app.recall_identity = MagicMock(return_value=None)
- with pytest.raises(Exception, match="Could not find path to destination"):
+ with pytest.raises(LookupError, match="Could not recall destination identity"):
await mock_app.send_message(
destination_hash=destination_hash,
content="hi",
)
+@pytest.mark.asyncio
+async def test_send_message_blocks_when_path_unavailable(mock_app):
+ destination_hash = "aa" * 16
+ fake_identity = MagicMock()
+ mock_app.recall_identity = MagicMock(return_value=fake_identity)
+ mock_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
+ with pytest.raises(TimeoutError, match="No path to destination"):
+ await mock_app.send_message(
+ destination_hash=destination_hash,
+ content="hi",
+ delivery_method="direct",
+ )
+ mock_app.message_router.handle_outbound.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_send_message_propagated_allows_missing_peer_path(mock_app):
+ destination_hash = "aa" * 16
+ fake_identity = MagicMock()
+ mock_app.recall_identity = MagicMock(return_value=fake_identity)
+ mock_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
+ mock_app.config.auto_send_failed_messages_to_propagation_node.get.return_value = (
+ False
+ )
+ mock_app.config.include_display_name_with_message.get.return_value = False
+ mock_app.config.include_icon_with_message.get.return_value = False
+ mock_app.config.include_signature_with_message.get.return_value = False
+ mock_msg = MagicMock()
+ mock_msg.hash = b"\x01" * 16
+ mock_msg.fields = {}
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=MagicMock()),
+ patch("meshchatx.meshchat.LXMF.LXMessage", return_value=mock_msg),
+ patch("meshchatx.meshchat.RNS.Identity.current_ratchet_id", return_value=None),
+ patch(
+ "meshchatx.meshchat.convert_lxmf_message_to_dict",
+ return_value={"hash": "01" * 16, "state": "outbound"},
+ ),
+ ):
+ result = await mock_app.send_message(
+ destination_hash=destination_hash,
+ content="hi",
+ delivery_method="propagated",
+ )
+ assert result is mock_msg
+ mock_app.message_router.handle_outbound.assert_called_once()
+ mock_app._await_transport_path.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_send_message_immediate_exception_in_router(mock_app):
destination_hash = "aa" * 16
@@ -244,11 +297,11 @@ async def test_send_message_await_path_timeout(mock_app):
return_value=OutboundPathOutcome(False, "new_path_requested", True),
)
destination_hash = "aa" * 16
+ fake_identity = MagicMock()
+ mock_app.recall_identity = MagicMock(return_value=fake_identity)
- # Even if _await_transport_path returns False, it continues to recall identity
- with patch("meshchatx.meshchat.RNS.Identity.recall", return_value=None):
- with pytest.raises(Exception, match="Could not find path to destination"):
- await mock_app.send_message(
- destination_hash=destination_hash,
- content="hi",
- )
+ with pytest.raises(TimeoutError, match="No path to destination"):
+ await mock_app.send_message(
+ destination_hash=destination_hash,
+ content="hi",
+ )
diff --git a/tests/backend/test_ping_api.py b/tests/backend/test_ping_api.py
new file mode 100644
index 00000000..cf7ff67f
--- /dev/null
+++ b/tests/backend/test_ping_api.py
@@ -0,0 +1,70 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Regression tests for the LXMF delivery ping HTTP endpoint."""
+
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+
+def _find_handler(app, path, method):
+ for route in app.get_routes():
+ if route.path == path and route.method == method:
+ return route.handler
+ return None
+
+
+def _make_request(match_info=None, query=None):
+ request = MagicMock()
+ request.match_info = match_info or {}
+ request.query = query or {}
+ return request
+
+
+@pytest.mark.asyncio
+async def test_ping_rejects_invalid_destination_hash(mock_app):
+ handler = _find_handler(
+ mock_app, "/api/v1/ping/{destination_hash}/lxmf.delivery", "GET"
+ )
+ assert handler is not None
+ response = await handler(
+ _make_request(
+ match_info={"destination_hash": "not-hex"}, query={"timeout": "5"}
+ )
+ )
+ assert response.status == 400
+ data = json.loads(response.body)
+ assert "Invalid destination hash" in data["message"]
+
+
+@pytest.mark.asyncio
+async def test_ping_rejects_non_integer_timeout(mock_app):
+ handler = _find_handler(
+ mock_app, "/api/v1/ping/{destination_hash}/lxmf.delivery", "GET"
+ )
+ assert handler is not None
+ response = await handler(
+ _make_request(
+ match_info={"destination_hash": "ab" * 16},
+ query={"timeout": "abc"},
+ )
+ )
+ assert response.status == 400
+ data = json.loads(response.body)
+ assert "Timeout" in data["message"]
+
+
+@pytest.mark.asyncio
+async def test_ping_rejects_zero_timeout(mock_app):
+ handler = _find_handler(
+ mock_app, "/api/v1/ping/{destination_hash}/lxmf.delivery", "GET"
+ )
+ assert handler is not None
+ response = await handler(
+ _make_request(
+ match_info={"destination_hash": "ab" * 16},
+ query={"timeout": "0"},
+ )
+ )
+ assert response.status == 400
diff --git a/tests/backend/test_repository_server_manager.py b/tests/backend/test_repository_server_manager.py
index 765729ad..ed196056 100644
--- a/tests/backend/test_repository_server_manager.py
+++ b/tests/backend/test_repository_server_manager.py
@@ -2,6 +2,7 @@
import time
import urllib.request
+from pathlib import Path
from unittest.mock import patch
import pytest
@@ -217,6 +218,36 @@ def test_refresh_bundled_wheels_fails_when_pypi_unavailable(
assert not out["downloaded"]
+@patch(
+ "meshchatx.src.backend.repository_server_manager.stage_local_meshchatx_wheel_into_bundled_dir",
+ return_value=None,
+)
+@patch("meshchatx.src.backend.repository_server_manager._download_wheel_via_pypi_index")
+def test_refresh_preserves_existing_wheels_when_pypi_fails(
+ mock_pypi, _mock_stage, tmp_path, monkeypatch
+):
+ monkeypatch.setenv("MESHCHAT_REPOSITORY_EXTRA_PIP", "")
+ mock_pypi.return_value = (False, "offline")
+ mgr = RepositoryServerManager(str(tmp_path))
+ keep = Path(mgr.bundled_dir) / "keep-me.whl"
+ keep.write_bytes(b"wheel")
+ out = mgr.refresh_bundled_wheels()
+ assert out["ok"] is False
+ assert keep.exists()
+ assert keep.read_bytes() == b"wheel"
+
+
+def test_refresh_rejects_concurrent_calls(tmp_path):
+ mgr = RepositoryServerManager(str(tmp_path))
+ assert mgr._refresh_lock.acquire(blocking=False)
+ try:
+ out = mgr.refresh_bundled_wheels()
+ assert out["ok"] is False
+ assert out.get("error") == "refresh_already_running"
+ finally:
+ mgr._refresh_lock.release()
+
+
def test_http_start_stop_and_status(tmp_path):
mgr = RepositoryServerManager(str(tmp_path))
assert mgr.status()["http"]["running"] is False
diff --git a/tests/backend/test_rncp_handler_extended.py b/tests/backend/test_rncp_handler_extended.py
index 399a5861..530fe0fd 100644
--- a/tests/backend/test_rncp_handler_extended.py
+++ b/tests/backend/test_rncp_handler_extended.py
@@ -1,5 +1,7 @@
# SPDX-License-Identifier: 0BSD
+import os
+import shutil
from unittest.mock import MagicMock, patch
import pytest
@@ -312,3 +314,52 @@ def test_setup_defaults_jail_when_fetch_enabled(
assert rncp_handler.fetch_jail
assert rncp_handler.fetch_jail.endswith("rncp_shared")
+
+
+def test_default_fetch_save_dir_under_storage(rncp_handler, tmp_path):
+ path = rncp_handler._default_fetch_save_dir()
+ assert path.endswith(os.path.join("rncp", "downloads"))
+ assert os.path.isdir(path)
+
+
+def test_cancel_transfer_marks_active(rncp_handler):
+ rncp_handler.active_transfers["abc"] = {"status": "sending"}
+ out = rncp_handler.cancel_transfer("abc")
+ assert out["cancelled"] == ["abc"]
+ assert rncp_handler.active_transfers["abc"]["status"] == "cancelled"
+ assert rncp_handler._is_cancelled("abc")
+
+
+def test_fetch_resource_concluded_sets_resolved_on_save_error(rncp_handler, tmp_path):
+ """Save failures must resolve the waiter instead of hanging forever."""
+ import RNS
+
+ resource_resolved = {"value": False}
+ resource_status = {"value": "unrequested"}
+ save_error = {"value": None}
+ effective_save_path = str(tmp_path / "readonly")
+ os.makedirs(effective_save_path, exist_ok=True)
+ os.chmod(effective_save_path, 0o500)
+
+ resource = MagicMock()
+ resource.status = RNS.Resource.COMPLETE
+ resource.metadata = {"name": b"file.txt"}
+ tmpdata = tmp_path / "tmpdata"
+ tmpdata.write_text("x")
+ resource.data.name = str(tmpdata)
+
+ try:
+ filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
+ saved_filename = os.path.join(effective_save_path, filename)
+ shutil.move(resource.data.name, saved_filename)
+ resource_status["value"] = "completed"
+ except Exception as e:
+ resource_status["value"] = "error"
+ save_error["value"] = str(e)
+ finally:
+ resource_resolved["value"] = True
+
+ assert resource_resolved["value"] is True
+ assert resource_status["value"] == "error"
+ assert save_error["value"]
+ os.chmod(effective_save_path, 0o700)
diff --git a/tests/backend/test_rngit_sparse_fetcher.py b/tests/backend/test_rngit_sparse_fetcher.py
new file mode 100644
index 00000000..53494559
--- /dev/null
+++ b/tests/backend/test_rngit_sparse_fetcher.py
@@ -0,0 +1,128 @@
+# SPDX-License-Identifier: 0BSD
+
+from pathlib import Path
+
+import pytest
+
+from meshchatx.src.backend.rngit_sparse_fetcher import (
+ RngitFetchError,
+ RngitSparseFetcher,
+ tools_available,
+)
+
+
+def test_tools_available_missing_git():
+ ok, code = tools_available(which=lambda name: None)
+ assert ok is False
+ assert code == "git_missing"
+
+
+def test_tools_available_missing_remote_helper():
+ def which(name):
+ if name == "git":
+ return "/usr/bin/git"
+ return None
+
+ ok, code = tools_available(which=which)
+ assert ok is False
+ assert code == "git_remote_rns_missing"
+
+
+@pytest.mark.asyncio
+async def test_fetcher_requires_tools(tmp_path):
+ fetcher = RngitSparseFetcher(
+ work_root=str(tmp_path / "work"),
+ reticulum_config_dir=None,
+ which=lambda _n: None,
+ )
+ with pytest.raises(RngitFetchError) as exc:
+ await fetcher.fetch(
+ destination_hash="a" * 32,
+ group="g",
+ repository="r",
+ paths=["a.geojson"],
+ ref="HEAD",
+ job_id="job1",
+ timeout_seconds=5,
+ )
+ assert exc.value.code == "rngit_tools_unavailable"
+
+
+@pytest.mark.asyncio
+async def test_fetcher_cleanup_workdir_on_failure(tmp_path, monkeypatch):
+ work_root = tmp_path / "work"
+ work_root.mkdir()
+
+ async def fake_run_git(args, *, cwd, env, timeout, processes):
+ raise RngitFetchError("git_clone_failed", "boom")
+
+ monkeypatch.setattr(
+ "meshchatx.src.backend.rngit_sparse_fetcher._run_git",
+ fake_run_git,
+ )
+ fetcher = RngitSparseFetcher(
+ work_root=str(work_root),
+ reticulum_config_dir=None,
+ which=lambda n: f"/bin/{n}",
+ )
+ with pytest.raises(RngitFetchError):
+ await fetcher.fetch(
+ destination_hash="a" * 32,
+ group="g",
+ repository="r",
+ paths=["a.geojson"],
+ ref="HEAD",
+ job_id="jobx",
+ timeout_seconds=5,
+ )
+ assert not (work_root / "jobx").exists()
+
+
+@pytest.mark.asyncio
+async def test_fetcher_reads_sparse_files(tmp_path, monkeypatch):
+ work_root = tmp_path / "work"
+ work_root.mkdir()
+ calls = []
+
+ async def fake_run_git(args, *, cwd, env, timeout, processes):
+ calls.append(args)
+ if args[:2] == ["git", "clone"]:
+ dest = Path(args[-1])
+ dest.mkdir(parents=True, exist_ok=True)
+ (dest / "maps").mkdir(exist_ok=True)
+ (dest / "maps" / "layer.geojson").write_bytes(
+ b'{"type":"Point","coordinates":[0,0]}',
+ )
+ return 0, b"", b""
+ if "sparse-checkout" in args:
+ return 0, b"", b""
+ if "fetch" in args:
+ return 0, b"", b""
+ if "checkout" in args:
+ return 0, b"", b""
+ if "rev-parse" in args:
+ return 0, b"abc123\n", b""
+ return 1, b"", b"unknown"
+
+ monkeypatch.setattr(
+ "meshchatx.src.backend.rngit_sparse_fetcher._run_git",
+ fake_run_git,
+ )
+ fetcher = RngitSparseFetcher(
+ work_root=str(work_root),
+ reticulum_config_dir="/tmp/rns",
+ which=lambda n: f"/bin/{n}",
+ )
+ result = await fetcher.fetch(
+ destination_hash="a" * 32,
+ group="g",
+ repository="r",
+ paths=["maps/layer.geojson"],
+ ref="main",
+ job_id="jobok",
+ timeout_seconds=30,
+ )
+ assert result.resolved_ref == "abc123"
+ assert b"Point" in result.files["maps/layer.geojson"]
+ assert any("sparse-checkout" in c for c in calls)
+ assert not (work_root / "jobok").exists()
diff --git a/tests/backend/test_rnpath_trace_handler.py b/tests/backend/test_rnpath_trace_handler.py
new file mode 100644
index 00000000..33c052f0
--- /dev/null
+++ b/tests/backend/test_rnpath_trace_handler.py
@@ -0,0 +1,28 @@
+# SPDX-License-Identifier: 0BSD
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from meshchatx.src.backend.rnpath_trace_handler import RNPathTraceHandler
+
+
+@pytest.mark.asyncio
+async def test_trace_includes_destination_for_zero_hops():
+ identity = MagicMock()
+ identity.hash = bytes.fromhex("11" * 16)
+ handler = RNPathTraceHandler(reticulum_instance=MagicMock(), identity=identity)
+ dest = "22" * 16
+
+ with patch("meshchatx.src.backend.rnpath_trace_handler.RNS.Transport") as transport:
+ transport.has_path.return_value = True
+ transport.hops_to.return_value = 0
+
+ result = await handler.trace_path(dest)
+
+ assert "error" not in result
+ assert result["hops"] == 0
+ assert any(
+ hop.get("type") == "destination" and hop.get("hash") == dest
+ for hop in result["path"]
+ )
diff --git a/tests/backend/test_rns_link_plugin.py b/tests/backend/test_rns_link_plugin.py
index 45a9d61a..96d639ee 100644
--- a/tests/backend/test_rns_link_plugin.py
+++ b/tests/backend/test_rns_link_plugin.py
@@ -93,7 +93,7 @@ class TestRnsLinkPluginCapabilities:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
_enable_with_link_perms(
manager,
plugin_id,
@@ -168,7 +168,7 @@ class TestRnsLinkPluginCapabilities:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
_enable_with_link_perms(
manager,
plugin_id,
@@ -218,7 +218,7 @@ class TestRnsLinkPluginCapabilities:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
_enable_with_link_perms(manager, plugin_id, managers=["rnsLink.request"])
result = manager.call_manager(
plugin_id,
@@ -243,7 +243,7 @@ class TestRnsLinkPluginCapabilities:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
manager.dispatch_hook = lambda *args: events.append(args)
manager.on_rns_link_event(
@@ -296,7 +296,7 @@ class TestRnsLinkPluginCapabilities:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.mesh-observatory"
+ plugin_id = "com.meshchatx.mcx-bugs"
_enable_with_link_perms(
manager,
plugin_id,
diff --git a/tests/backend/test_rns_startup_recovery.py b/tests/backend/test_rns_startup_recovery.py
new file mode 100644
index 00000000..d0bd8429
--- /dev/null
+++ b/tests/backend/test_rns_startup_recovery.py
@@ -0,0 +1,160 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Regression tests for RNS panic containment and progressive startup recovery."""
+
+from __future__ import annotations
+
+from RNS.vendor.configobj import ConfigObj
+
+from meshchatx.src.backend import rns_startup_recovery as recovery
+
+
+def test_install_rns_panic_containment_raises_instead_of_exit(monkeypatch):
+ import RNS
+
+ calls = {"exit": 0}
+
+ def fake_exit(code=255):
+ calls["exit"] += 1
+ raise SystemExit(code)
+
+ monkeypatch.setattr(RNS, "panic", lambda: fake_exit(255), raising=False)
+ assert recovery.install_rns_panic_containment(force=True) is True
+ try:
+ RNS.panic()
+ assert False, "expected RnsPanicError"
+ except recovery.RnsPanicError:
+ pass
+ assert calls["exit"] == 0
+
+
+def test_ensure_panic_on_interface_error_disabled(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+enable_transport = True
+panic_on_interface_error = Yes
+[interfaces]
+""",
+ encoding="utf-8",
+ )
+ assert recovery.ensure_panic_on_interface_error_disabled(str(config_path)) is True
+ cfg = ConfigObj(str(config_path))
+ assert str(cfg["reticulum"]["panic_on_interface_error"]).lower() in (
+ "no",
+ "false",
+ "0",
+ )
+
+
+def test_create_reticulum_with_recovery_disables_named_then_retries(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+enable_transport = True
+[interfaces]
+[[BadIface]]
+type = AutoInterface
+interface_enabled = true
+[[Good]]
+type = TCPClientInterface
+interface_enabled = true
+""",
+ encoding="utf-8",
+ )
+ calls = {"n": 0}
+
+ def construct():
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise RuntimeError('The interface "BadIface" failed to start')
+ return "ok"
+
+ result = recovery.create_reticulum_with_recovery(
+ str(tmp_path),
+ construct=construct,
+ max_attempts=3,
+ )
+ assert result == "ok"
+ assert calls["n"] == 2
+ cfg = ConfigObj(str(config_path))
+ assert str(cfg["interfaces"]["BadIface"]["interface_enabled"]).lower() in (
+ "false",
+ "no",
+ "0",
+ )
+ assert str(cfg["interfaces"]["Good"]["interface_enabled"]).lower() in (
+ "true",
+ "yes",
+ "1",
+ )
+
+
+def test_create_reticulum_with_recovery_escalates_to_i2p(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+enable_transport = True
+[interfaces]
+[[I2P]]
+type = I2PInterface
+interface_enabled = true
+peers = aaa.b32.i2p
+""",
+ encoding="utf-8",
+ )
+ calls = {"n": 0}
+
+ def construct():
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise RuntimeError("generic I2P brick")
+ return "ok"
+
+ assert (
+ recovery.create_reticulum_with_recovery(
+ str(tmp_path),
+ construct=construct,
+ )
+ == "ok"
+ )
+ cfg = ConfigObj(str(config_path))
+ assert str(cfg["interfaces"]["I2P"]["interface_enabled"]).lower() in (
+ "false",
+ "no",
+ "0",
+ )
+
+
+def test_extract_interface_names_from_error():
+ names = recovery.extract_interface_names_from_error(
+ 'AutoInterface[HomeLAN] failed; also interface "Radio1" offline',
+ )
+ assert "HomeLAN" in names
+ assert "Radio1" in names
+
+
+def test_apply_startup_recovery_step_autointerface(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+enable_transport = True
+[interfaces]
+[[Home]]
+type = AutoInterface
+interface_enabled = true
+""",
+ encoding="utf-8",
+ )
+ disabled = recovery.apply_startup_recovery_step(
+ str(config_path),
+ "bind failed",
+ attempt=2,
+ )
+ assert "Home" in disabled
+ cfg = ConfigObj(str(config_path))
+ assert str(cfg["interfaces"]["Home"]["interface_enabled"]).lower() in (
+ "false",
+ "no",
+ "0",
+ )
diff --git a/tests/backend/test_sqlite_landlock_temp_store.py b/tests/backend/test_sqlite_landlock_temp_store.py
new file mode 100644
index 00000000..17b13f1a
--- /dev/null
+++ b/tests/backend/test_sqlite_landlock_temp_store.py
@@ -0,0 +1,262 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Regression: worker-thread SQLite connections must use MEMORY temp under Landlock."""
+
+import subprocess
+import sys
+import textwrap
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import pytest
+
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported
+
+
+@pytest.fixture(autouse=True)
+def reset_provider():
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+ yield
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+
+
+def test_provider_configures_temp_store_memory_on_new_connections(tmp_path):
+ db_path = str(tmp_path / "database.db")
+ provider = DatabaseProvider.get_instance(db_path)
+ conn = provider.connection
+ mode = conn.execute("PRAGMA temp_store").fetchone()[0]
+ # SQLite returns 0=DEFAULT, 1=FILE, 2=MEMORY
+ assert int(mode) == 2
+
+
+def test_provider_memory_pressure_prefers_file_temp(tmp_path):
+ db_path = str(tmp_path / "database.db")
+ provider = DatabaseProvider.get_instance(db_path)
+ provider.prefer_temp_store_file = True
+ provider.close()
+ conn = provider.connection
+ mode = conn.execute("PRAGMA temp_store").fetchone()[0]
+ assert int(mode) == 1
+
+
+def test_worker_threads_inherit_memory_temp_store(tmp_path):
+ db_path = str(tmp_path / "database.db")
+ provider = DatabaseProvider.get_instance(db_path)
+ modes: list[int] = []
+ errors: list[str] = []
+
+ def worker(_i: int) -> None:
+ try:
+ conn = provider.connection
+ modes.append(int(conn.execute("PRAGMA temp_store").fetchone()[0]))
+ except Exception as exc:
+ errors.append(str(exc))
+
+ with ThreadPoolExecutor(max_workers=8) as pool:
+ list(pool.map(worker, range(16)))
+
+ assert not errors
+ assert modes
+ assert all(mode == 2 for mode in modes)
+
+
+@pytest.mark.skipif(
+ not landlock_kernel_supported(),
+ reason="Landlock not available on this kernel",
+)
+def test_landlock_worker_heavy_query_ok_with_memory_temp():
+ """Worker-thread heavy conversation queries must work under Landlock.
+
+ Runs in a subprocess because Landlock can only restrict a process once.
+ """
+ script = textwrap.dedent(
+ r"""
+ import os, sqlite3, sys, tempfile, time
+ from concurrent.futures import ThreadPoolExecutor
+ from meshchatx.src.backend.database.provider import DatabaseProvider
+ from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
+
+ td = tempfile.mkdtemp(prefix="ll_sqlite_reg_")
+ storage = os.path.join(td, "storage")
+ os.makedirs(storage)
+ os.environ["MESHCHAT_LANDLOCK"] = "1"
+
+ db = os.path.join(storage, "database.db")
+ conn = sqlite3.connect(db)
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute(
+ "CREATE TABLE lxmf_messages ("
+ "id INTEGER PRIMARY KEY, peer_hash TEXT, content TEXT, fields TEXT, "
+ "title TEXT, timestamp REAL, is_incoming INT, state TEXT)"
+ )
+ big_content = "x" * 200000
+ big_fields = '{"image":{"image_bytes":"' + ("A" * 40000) + '"}}'
+ for i in range(80):
+ conn.execute(
+ "INSERT INTO lxmf_messages VALUES (?,?,?,?,?,?,?,?)",
+ (i, f"peer{i%16}", big_content, big_fields, "t", time.time(), 1, "delivered"),
+ )
+ conn.commit()
+ conn.close()
+
+ ok = apply_landlock_sandbox(
+ storage_dir=storage,
+ reticulum_config_dir=storage,
+ log_dir=storage,
+ )
+ if not ok:
+ print("LANDLOCK_NOT_APPLIED")
+ sys.exit(2)
+
+ heavy = '''
+ SELECT m1.id, substr(COALESCE(m1.content,''),1,240) AS content,
+ CASE WHEN instr(m1.fields,'"image"')>0 THEN 1 ELSE 0 END AS has_image
+ FROM lxmf_messages m1
+ INNER JOIN (
+ SELECT peer_hash, MAX(id) AS max_id FROM lxmf_messages
+ WHERE peer_hash IS NOT NULL GROUP BY peer_hash
+ ) m2 ON m1.peer_hash=m2.peer_hash AND m1.id=m2.max_id
+ GROUP BY m1.peer_hash ORDER BY m1.id DESC LIMIT 50
+ '''
+
+ DatabaseProvider._instance = None
+ provider = DatabaseProvider.get_instance(db)
+ mem_errors = []
+ def mem_worker(_i):
+ try:
+ rows = provider.fetchall(heavy)
+ if not rows:
+ mem_errors.append("empty")
+ except Exception as e:
+ mem_errors.append(str(e))
+
+ with ThreadPoolExecutor(max_workers=8) as ex:
+ list(ex.map(mem_worker, range(24)))
+
+ print("MEM_ERRORS", len(mem_errors))
+ if mem_errors:
+ print(mem_errors[:3])
+ sys.exit(4)
+ sys.exit(0)
+ """
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+ if "LANDLOCK_NOT_APPLIED" in result.stdout:
+ pytest.skip("Landlock could not be applied in this environment")
+ assert result.returncode == 0, (
+ f"stdout={result.stdout!r} stderr={result.stderr!r} code={result.returncode}"
+ )
+ assert "MEM_ERRORS 0" in result.stdout
+
+
+@pytest.mark.skipif(
+ not landlock_kernel_supported(),
+ reason="Landlock not available on this kernel",
+)
+def test_landlock_memory_pressure_keeps_memory_temp_and_queries_ok():
+ """Memory-pressure must not switch to FILE temp under Landlock."""
+ script = textwrap.dedent(
+ r"""
+ import os, sqlite3, sys, tempfile, time
+ from concurrent.futures import ThreadPoolExecutor
+ from meshchatx.src.backend.database import Database
+ from meshchatx.src.backend.database.provider import DatabaseProvider
+ from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox
+
+ td = tempfile.mkdtemp(prefix="ll_pressure_")
+ storage = os.path.join(td, "storage")
+ os.makedirs(storage)
+ os.environ["MESHCHAT_LANDLOCK"] = "1"
+ db_path = os.path.join(storage, "database.db")
+
+ conn = sqlite3.connect(db_path)
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute(
+ "CREATE TABLE lxmf_messages ("
+ "id INTEGER PRIMARY KEY, peer_hash TEXT, content TEXT, fields TEXT, "
+ "title TEXT, timestamp REAL, is_incoming INT, state TEXT)"
+ )
+ big_content = "x" * 120000
+ big_fields = '{"image":{"image_bytes":"' + ("A" * 60000) + '"}}'
+ for i in range(200):
+ conn.execute(
+ "INSERT INTO lxmf_messages VALUES (?,?,?,?,?,?,?,?)",
+ (i, f"peer{i%40}", big_content, big_fields, "t", time.time(), 1, "delivered"),
+ )
+ conn.commit()
+ conn.close()
+
+ ok = apply_landlock_sandbox(
+ storage_dir=storage,
+ reticulum_config_dir=storage,
+ log_dir=storage,
+ )
+ if not ok:
+ print("LANDLOCK_NOT_APPLIED")
+ sys.exit(2)
+
+ DatabaseProvider._instance = None
+ db = Database(db_path)
+ # Skip full schema init; only need pressure pragma path + provider.
+ assert db.apply_memory_pressure_pragmas(True, landlock_active=True)
+ mode = int(db.provider.connection.execute("PRAGMA temp_store").fetchone()[0])
+ print("TEMP_MODE", mode)
+ if mode != 2:
+ sys.exit(3)
+ if db.provider.prefer_temp_store_file:
+ sys.exit(4)
+
+ heavy = '''
+ SELECT m1.id, substr(COALESCE(m1.content,''),1,240) AS content,
+ CASE WHEN instr(m1.fields,'"image"')>0 THEN 1 ELSE 0 END AS has_image
+ FROM lxmf_messages m1
+ INNER JOIN (
+ SELECT peer_hash, MAX(id) AS max_id FROM lxmf_messages
+ WHERE peer_hash IS NOT NULL GROUP BY peer_hash
+ ) m2 ON m1.peer_hash=m2.peer_hash AND m1.id=m2.max_id
+ GROUP BY m1.peer_hash ORDER BY m1.id DESC LIMIT 50
+ '''
+ errors = []
+ def worker(_i):
+ try:
+ rows = db.provider.fetchall(heavy)
+ if not rows:
+ errors.append("empty")
+ except Exception as e:
+ errors.append(str(e))
+ with ThreadPoolExecutor(max_workers=8) as ex:
+ list(ex.map(worker, range(24)))
+ print("PRESSURE_ERRORS", len(errors))
+ if errors:
+ print(errors[:3])
+ sys.exit(5)
+ sys.exit(0)
+ """
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+ if "LANDLOCK_NOT_APPLIED" in result.stdout:
+ pytest.skip("Landlock could not be applied in this environment")
+ assert result.returncode == 0, (
+ f"stdout={result.stdout!r} stderr={result.stderr!r} code={result.returncode}"
+ )
+ assert "TEMP_MODE 2" in result.stdout
+ assert "PRESSURE_ERRORS 0" in result.stdout
diff --git a/tests/backend/test_sqlite_memory_pressure.py b/tests/backend/test_sqlite_memory_pressure.py
index d1876c5b..ed1a67e8 100644
--- a/tests/backend/test_sqlite_memory_pressure.py
+++ b/tests/backend/test_sqlite_memory_pressure.py
@@ -9,6 +9,19 @@ def test_apply_memory_pressure_pragmas_roundtrip(tmp_path):
assert db.apply_memory_pressure_pragmas(True) is True
assert db._sqlite_memory_relaxed is True
assert db._get_pragma_value("temp_store") == 1 # FILE
+ assert db.provider.prefer_temp_store_file is True
assert db.apply_memory_pressure_pragmas(False) is True
assert db._sqlite_memory_relaxed is False
assert db._get_pragma_value("temp_store") == 2 # MEMORY
+ assert db.provider.prefer_temp_store_file is False
+
+
+def test_memory_pressure_keeps_memory_temp_under_landlock(tmp_path):
+ db = Database(str(tmp_path / "pressure_ll.db"))
+ db.initialize()
+ assert db.apply_memory_pressure_pragmas(True, landlock_active=True) is True
+ assert db._sqlite_memory_relaxed is True
+ assert db._get_pragma_value("temp_store") == 2 # MEMORY
+ assert db.provider.prefer_temp_store_file is False
+ assert db._get_pragma_value("cache_size") == -2000
+ assert db._get_pragma_value("mmap_size") == 0
diff --git a/tests/frontend/AddInterfaceOptions.test.js b/tests/frontend/AddInterfaceOptions.test.js
index d1826a5a..03ea8b92 100644
--- a/tests/frontend/AddInterfaceOptions.test.js
+++ b/tests/frontend/AddInterfaceOptions.test.js
@@ -36,7 +36,18 @@ const mountPage = () =>
describe("AddInterfacePage.vue interface options", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockAxios.get.mockResolvedValue({ data: {} });
+ mockAxios.get.mockImplementation(async (url) => {
+ if (String(url).includes("/api/v1/config")) {
+ return { data: { config: { is_transport_enabled: true } } };
+ }
+ if (String(url).includes("/api/v1/reticulum/instance")) {
+ return { data: { instance: { enable_transport: true } } };
+ }
+ if (String(url).includes("/api/v1/reticulum/interfaces")) {
+ return { data: { interfaces: {} } };
+ }
+ return { data: {} };
+ });
mockAxios.post.mockResolvedValue({ data: { message: "ok" } });
});
@@ -256,6 +267,12 @@ describe("AddInterfacePage.vue interface options", () => {
wrapper.vm.newInterfaceType = "I2PInterface";
wrapper.vm.I2PSettings.newInterfacePeers = ["abcdef.b32.i2p"];
wrapper.vm.newInterfaceConnectable = false;
+ wrapper.vm.config = { ...(wrapper.vm.config || {}), is_transport_enabled: true };
+ wrapper.vm.reticulumInstance = {
+ ...(wrapper.vm.reticulumInstance || {}),
+ enable_transport: true,
+ };
+ wrapper.vm.existingInterfaces = {};
await wrapper.vm.saveInterface();
diff --git a/tests/frontend/AppPropagationSync.test.js b/tests/frontend/AppPropagationSync.test.js
index 2030b3ab..773ddc08 100644
--- a/tests/frontend/AppPropagationSync.test.js
+++ b/tests/frontend/AppPropagationSync.test.js
@@ -28,20 +28,30 @@ function makeSyncContext(axiosMock, tOverrides = {}) {
propagationNodeStatus: null,
_propagationSyncPollTimer: null,
_isPropagationSyncPolling: false,
+ userInitiatedPropagationSync: false,
propagationSyncLiveToastMessage: App.methods.propagationSyncLiveToastMessage,
propagationSyncStatusLabel: App.methods.propagationSyncStatusLabel,
get isSyncingPropagationNode() {
+ if (!this.userInitiatedPropagationSync) {
+ return false;
+ }
return syncingStates.includes(this.propagationNodeStatus?.state);
},
async updatePropagationNodeStatus() {
try {
const response = await axiosMock.get("/api/v1/lxmf/propagation-node/status");
this.propagationNodeStatus = response.data.propagation_node_status;
+ const state = this.propagationNodeStatus?.state;
+ if (this.userInitiatedPropagationSync && state && !syncingStates.includes(state)) {
+ this.userInitiatedPropagationSync = false;
+ }
} catch {
// ignore
}
},
- async stopSyncingPropagationNode() {},
+ async stopSyncingPropagationNode() {
+ this.userInitiatedPropagationSync = false;
+ },
$t(key, params = {}) {
if (tOverrides[key]) {
return tOverrides[key](params);
diff --git a/tests/frontend/BootLoadSmoothness.test.js b/tests/frontend/BootLoadSmoothness.test.js
new file mode 100644
index 00000000..698b4392
--- /dev/null
+++ b/tests/frontend/BootLoadSmoothness.test.js
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import {
+ MESHCHAT_THEME_VARIABLES_LIGHT,
+ MESHCHAT_THEME_VARIABLES_DARK,
+ injectMeshchatThemeVariables,
+} from "../../meshchatx/src/frontend/theme/designTokens.js";
+
+const ROOT = resolve(import.meta.dirname, "../..");
+
+describe("boot and load smoothness", () => {
+ beforeEach(() => {
+ document.head.innerHTML = "";
+ document.body.innerHTML = "";
+ document.documentElement.className = "";
+ });
+
+ afterEach(() => {
+ document.head.innerHTML = "";
+ document.body.innerHTML = "";
+ document.documentElement.className = "";
+ });
+
+ it("index.html uses canvas-colored body instead of gray-100 flash", () => {
+ const html = readFileSync(resolve(ROOT, "meshchatx/src/frontend/index.html"), "utf8");
+ expect(html).not.toMatch(/body class="bg-gray-100"/);
+ expect(html).toContain("background-color: #f8fafc");
+ expect(html).toContain("background-color: #09090b");
+ expect(html).toContain('id="meshchatx-boot-splash"');
+ expect(html).toContain('id="app"');
+ });
+
+ it("style.css paints html/body/#app with semantic canvas", () => {
+ const css = readFileSync(resolve(ROOT, "meshchatx/src/frontend/style.css"), "utf8");
+ expect(css).toContain("background-color: var(--mc-canvas");
+ expect(css).toContain("#app");
+ expect(css).toContain(".route-view-fade-enter-active");
+ });
+
+ it("main.js defers splash removal and preloads critical routes", () => {
+ const main = readFileSync(resolve(ROOT, "meshchatx/src/frontend/main.js"), "utf8");
+ expect(main).toContain("removeBootSplash");
+ expect(main).toContain("requestAnimationFrame");
+ expect(main).toContain("preloadCriticalRouteChunks");
+ expect(main).toContain('import("./components/messages/MessagesPage.vue")');
+ });
+
+ it("App.vue fades non-keepAlive route swaps on canvas background", () => {
+ const app = readFileSync(resolve(ROOT, "meshchatx/src/frontend/components/App.vue"), "utf8");
+ expect(app).toContain('name="route-view-fade"');
+ expect(app).toContain("bg-sem-canvas");
+ });
+
+ it("Android theme and WebView use meshchat canvas color", () => {
+ const colors = readFileSync(resolve(ROOT, "android/app/src/main/res/values/colors.xml"), "utf8");
+ const themes = readFileSync(resolve(ROOT, "android/app/src/main/res/values/themes.xml"), "utf8");
+ const layout = readFileSync(resolve(ROOT, "android/app/src/main/res/layout/activity_main.xml"), "utf8");
+ const activity = readFileSync(
+ resolve(ROOT, "android/app/src/main/java/com/meshchatx/MainActivity.java"),
+ "utf8"
+ );
+
+ expect(colors).toContain("meshchat_canvas");
+ expect(colors).toContain("#FFF8FAFC");
+ expect(themes).toContain("android:windowBackground");
+ expect(layout).toContain("@color/meshchat_canvas");
+ expect(activity).toContain("setBackgroundColor(canvasColor)");
+ expect(activity).toContain("R.color.meshchat_canvas");
+ });
+
+ it("injectMeshchatThemeVariables keeps light/dark canvas tokens aligned", () => {
+ injectMeshchatThemeVariables(document);
+ const style = document.getElementById("meshchat-design-tokens");
+ expect(style).toBeTruthy();
+ expect(style.textContent).toContain(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]);
+ expect(style.textContent).toContain(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]);
+ expect(MESHCHAT_THEME_VARIABLES_LIGHT["--mc-canvas"]).toBe("#f8fafc");
+ expect(MESHCHAT_THEME_VARIABLES_DARK["--mc-canvas"]).toBe("#09090b");
+ });
+
+ it("removeBootSplash fades then removes without leaving white gap", async () => {
+ vi.useFakeTimers();
+ const splash = document.createElement("div");
+ splash.id = "meshchatx-boot-splash";
+ splash.setAttribute("aria-busy", "true");
+ document.body.appendChild(splash);
+
+ function removeBootSplash(el) {
+ if (!el || !el.isConnected) {
+ return;
+ }
+ el.setAttribute("aria-busy", "false");
+ el.style.transition = "opacity 140ms ease";
+ el.style.opacity = "0";
+ window.setTimeout(() => {
+ if (el.isConnected) {
+ el.remove();
+ }
+ }, 160);
+ }
+
+ removeBootSplash(splash);
+ expect(splash.getAttribute("aria-busy")).toBe("false");
+ expect(splash.style.opacity).toBe("0");
+ expect(document.getElementById("meshchatx-boot-splash")).toBeTruthy();
+
+ vi.advanceTimersByTime(160);
+ expect(document.getElementById("meshchatx-boot-splash")).toBeNull();
+ vi.useRealTimers();
+ });
+});
diff --git a/tests/frontend/ConversationDropDownMenu.shareApk.test.js b/tests/frontend/ConversationDropDownMenu.shareApk.test.js
new file mode 100644
index 00000000..77ecaf98
--- /dev/null
+++ b/tests/frontend/ConversationDropDownMenu.shareApk.test.js
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { mount } from "@vue/test-utils";
+import ConversationDropDownMenu from "../../meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue";
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(async () => true),
+ alert: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
+ default: {
+ blockedDestinations: [],
+ config: { telemetry_enabled: false },
+ },
+}));
+
+const shareApkMock = vi.fn(() => true);
+
+vi.mock("../../meshchatx/src/frontend/js/rnode/AndroidBridge.js", () => ({
+ default: class AndroidBridge {
+ shareApk() {
+ return shareApkMock();
+ }
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ error: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+const peer = {
+ destination_hash: "a".repeat(32),
+ display_name: "Peer",
+};
+
+function mountMenu(compact = true) {
+ return mount(ConversationDropDownMenu, {
+ props: { peer, compact, hasFailedMessages: false },
+ global: {
+ mocks: { $t: (k) => k },
+ stubs: {
+ DropDownMenu: {
+ template: "<div><slot name='button' /><slot name='items' /></div>",
+ },
+ DropDownMenuItem: { template: "<button @click='$emit(\"click\")'><slot /></button>" },
+ IconButton: {
+ props: ["title"],
+ template: "<button :title='title' @click='$emit(\"click\")'><slot /></button>",
+ },
+ MaterialDesignIcon: true,
+ },
+ },
+ });
+}
+
+describe("ConversationDropDownMenu share APK", () => {
+ beforeEach(() => {
+ shareApkMock.mockReset().mockReturnValue(true);
+ delete window.MeshChatXAndroid;
+ });
+
+ afterEach(() => {
+ delete window.MeshChatXAndroid;
+ });
+
+ it("hides share APK when not on Android", () => {
+ const wrapper = mountMenu(true);
+ expect(wrapper.text()).not.toContain("messages.share_apk");
+ wrapper.unmount();
+ });
+
+ it("shows share APK on Android and opens share sheet", async () => {
+ window.MeshChatXAndroid = {
+ getPlatform: () => "android",
+ shareApk: vi.fn(),
+ };
+ const wrapper = mountMenu(true);
+ expect(wrapper.text()).toContain("messages.share_apk");
+ await wrapper.vm.onShareApk();
+ expect(shareApkMock).toHaveBeenCalled();
+ wrapper.unmount();
+ });
+
+ it("shows share APK icon button in non-compact Android mode", () => {
+ window.MeshChatXAndroid = {
+ getPlatform: () => "android",
+ shareApk: vi.fn(),
+ };
+ const wrapper = mountMenu(false);
+ const btn = wrapper.findAll("button").find((b) => b.attributes("title") === "messages.share_apk");
+ expect(btn).toBeTruthy();
+ wrapper.unmount();
+ });
+});
diff --git a/tests/frontend/ConversationMobileChrome.test.js b/tests/frontend/ConversationMobileChrome.test.js
new file mode 100644
index 00000000..276a91f8
--- /dev/null
+++ b/tests/frontend/ConversationMobileChrome.test.js
@@ -0,0 +1,142 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ConversationPeerHeader from "../../meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue";
+import ConversationDropDownMenu from "../../meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState.js";
+
+const peer = {
+ destination_hash: "a".repeat(32),
+ display_name: "Test Peer",
+};
+
+function mountHeader(props = {}) {
+ return mount(ConversationPeerHeader, {
+ props: {
+ selectedPeer: peer,
+ ...props,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: true,
+ IconButton: {
+ template: '<button type="button" v-bind="$attrs"><slot /></button>',
+ },
+ DropDownMenu: {
+ template:
+ '<div class="dd"><slot name="button" /><div class="dd-items"><slot name="items" /></div></div>',
+ },
+ DropDownMenuItem: {
+ template: '<div class="dd-item" v-bind="$attrs" @click="$emit(\'click\')"><slot /></div>',
+ },
+ LxmfUserIcon: true,
+ ConversationDropDownMenu: {
+ name: "ConversationDropDownMenu",
+ props: ["peer", "compact", "hasFailedMessages", "pathfinderInProgress"],
+ template:
+ "<div data-testid=\"conversation-menu\" :data-compact=\"compact ? '1' : '0'\" :data-pathfinder=\"pathfinderInProgress ? '1' : '0'\"></div>",
+ emits: [
+ "path-finder-quick",
+ "path-finder-force",
+ "path-finder-drop",
+ "popout",
+ "conversation-deleted",
+ "set-custom-display-name",
+ "retry-failed",
+ "open-telemetry-history",
+ "start-call",
+ "share-contact",
+ ],
+ },
+ },
+ },
+ });
+}
+
+function mountMenu(props = {}) {
+ return mount(ConversationDropDownMenu, {
+ props: {
+ peer,
+ ...props,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: {
+ props: ["iconName"],
+ template: '<span class="mdi" :data-icon="iconName"></span>',
+ },
+ IconButton: {
+ template: '<button type="button" v-bind="$attrs"><slot /></button>',
+ },
+ DropDownMenu: {
+ template:
+ '<div class="dd"><slot name="button" /><div class="dd-items"><slot name="items" /></div></div>',
+ },
+ DropDownMenuItem: {
+ emits: ["click"],
+ template: '<div class="dd-item" v-bind="$attrs" @click="$emit(\'click\')"><slot /></div>',
+ },
+ },
+ },
+ });
+}
+
+describe("conversation mobile chrome", () => {
+ beforeEach(() => {
+ GlobalState.blockedDestinations = [];
+ GlobalState.config = { telemetry_enabled: false };
+ window.api = {
+ get: vi.fn().mockResolvedValue({ data: { is_contact: false } }),
+ };
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ it("hides standalone path-ops icon on mobile compact header", () => {
+ const wrapper = mountHeader({ compactPeerActions: true });
+ expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="conversation-menu"]').attributes("data-compact")).toBe("1");
+ });
+
+ it("shows standalone path-ops icon on desktop header", () => {
+ const wrapper = mountHeader({ compactPeerActions: false });
+ expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="conversation-menu"]').attributes("data-compact")).toBe("0");
+ });
+
+ it("puts path finder actions in compact 3-dots menu and hides popout", async () => {
+ const wrapper = mountMenu({ compact: true });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="path-finder-quick"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="path-finder-force"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="path-finder-drop"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain("nomadnet.path_finder_quick_request");
+ expect(wrapper.text()).not.toContain("messages.pop_out_chat");
+ expect(wrapper.find('[data-testid="conversation-popout"]').exists()).toBe(false);
+ });
+
+ it("emits path finder events from compact menu", async () => {
+ const wrapper = mountMenu({ compact: true });
+ await wrapper.vm.$nextTick();
+
+ await wrapper.find('[data-testid="path-finder-quick"]').trigger("click");
+ await wrapper.find('[data-testid="path-finder-force"]').trigger("click");
+ await wrapper.find('[data-testid="path-finder-drop"]').trigger("click");
+
+ expect(wrapper.emitted("path-finder-quick")).toHaveLength(1);
+ expect(wrapper.emitted("path-finder-force")).toHaveLength(1);
+ expect(wrapper.emitted("path-finder-drop")).toHaveLength(1);
+ });
+
+ it("keeps popout on desktop non-compact menu", async () => {
+ const wrapper = mountMenu({ compact: false });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="conversation-popout"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="path-finder-quick"]').exists()).toBe(false);
+ });
+});
diff --git a/tests/frontend/ConversationPeerHeader.test.js b/tests/frontend/ConversationPeerHeader.test.js
index 3a87b63b..8df689de 100644
--- a/tests/frontend/ConversationPeerHeader.test.js
+++ b/tests/frontend/ConversationPeerHeader.test.js
@@ -15,14 +15,20 @@ function mountHeader(props = {}) {
},
global: {
mocks: { $t: (key) => key },
- stubs: [
- "MaterialDesignIcon",
- "IconButton",
- "DropDownMenu",
- "DropDownMenuItem",
- "LxmfUserIcon",
- "ConversationDropDownMenu",
- ],
+ stubs: {
+ MaterialDesignIcon: true,
+ IconButton: true,
+ DropDownMenu: {
+ template: '<div class="dd" v-bind="$attrs"><slot name="button" /><slot name="items" /></div>',
+ },
+ DropDownMenuItem: true,
+ LxmfUserIcon: true,
+ ConversationDropDownMenu: {
+ name: "ConversationDropDownMenu",
+ props: ["peer", "compact", "hasFailedMessages", "pathfinderInProgress"],
+ template: '<div data-testid="conversation-menu"></div>',
+ },
+ },
},
});
}
@@ -58,4 +64,14 @@ describe("ConversationPeerHeader.vue path row", () => {
});
expect(wrapper.text()).toContain("messages.path_stale_label");
});
+
+ it("hides path-ops icon when compactPeerActions is true", () => {
+ const wrapper = mountHeader({ compactPeerActions: true });
+ expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(false);
+ });
+
+ it("shows path-ops icon when compactPeerActions is false", () => {
+ const wrapper = mountHeader({ compactPeerActions: false });
+ expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(true);
+ });
});
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 03c02f4f..3611cf86 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -155,6 +155,7 @@ describe("ConversationViewer.vue", () => {
await flushPromises();
expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
+ expect(conversation.is_unread).toBe(true);
});
it("onMessagePaste adds images from clipboard and prevents default", async () => {
@@ -718,6 +719,11 @@ describe("ConversationViewer.vue", () => {
it("sends multiple images as separate messages", async () => {
const wrapper = mountConversationViewer();
+ wrapper.vm.peerPathSnapshot = {
+ path: { hops: 1 },
+ path_stale: false,
+ path_unresponsive: false,
+ };
wrapper.vm.newMessageText = "Hello";
const image1 = new File([""], "image1.png", { type: "image/png" });
@@ -730,9 +736,18 @@ describe("ConversationViewer.vue", () => {
await wrapper.vm.onImageSelected(image1);
await wrapper.vm.onImageSelected(image2);
- axiosMock.post.mockResolvedValue({ data: { lxmf_message: { hash: "mock-hash" } } });
+ axiosMock.post.mockImplementation((url) => {
+ if (typeof url === "string" && (url.includes("/request-path") || url.includes("/drop-path"))) {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: { lxmf_message: { hash: "mock-hash" } } });
+ });
await wrapper.vm.sendMessage();
+ await vi.waitFor(() => {
+ const sendCalls = axiosMock.post.mock.calls.filter((c) => c[0] === "/api/v1/lxmf-messages/send");
+ expect(sendCalls.length).toBe(2);
+ });
const sendCalls = axiosMock.post.mock.calls.filter((c) => c[0] === "/api/v1/lxmf-messages/send");
expect(sendCalls.length).toBe(2);
@@ -1196,6 +1211,11 @@ describe("ConversationViewer.vue", () => {
it("sets reply state and includes reply_to_hash in sendMessage", async () => {
const wrapper = mountConversationViewer();
+ wrapper.vm.peerPathSnapshot = {
+ path: { hops: 1 },
+ path_stale: false,
+ path_unresponsive: false,
+ };
const chatItem = {
lxmf_message: { hash: "original-hash", content: "Original message" },
};
@@ -1207,19 +1227,25 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.replyingTo.lxmf_message.hash).toBe(chatItem.lxmf_message.hash);
wrapper.vm.newMessageText = "My reply";
- axiosMock.post.mockResolvedValue({ data: { lxmf_message: { hash: "reply-hash" } } });
+ axiosMock.post.mockImplementation((url) => {
+ if (typeof url === "string" && (url.includes("/request-path") || url.includes("/drop-path"))) {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: { lxmf_message: { hash: "reply-hash" } } });
+ });
await wrapper.vm.sendMessage();
-
- expect(axiosMock.post).toHaveBeenCalledWith(
- "/api/v1/lxmf-messages/send",
- expect.objectContaining({
- lxmf_message: expect.objectContaining({
- content: "My reply",
- reply_to_hash: "original-hash",
- }),
- })
- );
+ await vi.waitFor(() => {
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/lxmf-messages/send",
+ expect.objectContaining({
+ lxmf_message: expect.objectContaining({
+ content: "My reply",
+ reply_to_hash: "original-hash",
+ }),
+ })
+ );
+ });
expect(wrapper.vm.replyingTo).toBeNull();
});
diff --git a/tests/frontend/ConversationViewerReactions.test.js b/tests/frontend/ConversationViewerReactions.test.js
new file mode 100644
index 00000000..c678c620
--- /dev/null
+++ b/tests/frontend/ConversationViewerReactions.test.js
@@ -0,0 +1,306 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ConversationViewer from "@/components/messages/ConversationViewer.vue";
+import WebSocketConnection from "@/js/WebSocketConnection";
+import GlobalState from "@/js/GlobalState";
+import ToastUtils from "@/js/ToastUtils";
+
+describe("ConversationViewer reactions", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ GlobalState.config.theme = "light";
+ GlobalState.config.message_outbound_bubble_color = "#4f46e5";
+ GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
+ WebSocketConnection.connect();
+ axiosMock = {
+ get: vi.fn().mockResolvedValue({ data: {} }),
+ post: vi.fn().mockResolvedValue({
+ data: {
+ lxmf_message: {
+ hash: "reaction-hash",
+ is_reaction: true,
+ reaction_to: "msg-hash",
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "my-hash",
+ source_hash: "my-hash",
+ destination_hash: "test-hash",
+ },
+ },
+ }),
+ };
+ window.api = axiosMock;
+ vi.spyOn(ToastUtils, "error").mockImplementation(() => {});
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ delete window.api;
+ WebSocketConnection.destroy();
+ vi.restoreAllMocks();
+ });
+
+ const mountViewer = (props = {}) =>
+ mount(ConversationViewer, {
+ props: {
+ selectedPeer: { destination_hash: "test-hash", display_name: "Test Peer" },
+ myLxmfAddressHash: "my-hash",
+ conversations: [],
+ ...props,
+ },
+ global: {
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: { $t: (key) => key },
+ stubs: {
+ MaterialDesignIcon: true,
+ AddImageButton: true,
+ AddAudioButton: true,
+ SendMessageButton: true,
+ ConversationDropDownMenu: true,
+ PaperMessageModal: true,
+ AudioWaveformPlayer: true,
+ LxmfUserIcon: true,
+ "emoji-picker": true,
+ },
+ },
+ });
+
+ const parentChatItem = () => ({
+ type: "lxmf_message",
+ is_outbound: false,
+ lxmf_message: {
+ hash: "msg-hash",
+ content: "hello",
+ source_hash: "test-hash",
+ destination_hash: "my-hash",
+ reactions: [],
+ },
+ });
+
+ it("sends a reaction and optimistically attaches it to the parent", async () => {
+ const wrapper = mountViewer();
+ const chatItem = parentChatItem();
+ wrapper.vm.chatItems = [chatItem];
+
+ await wrapper.vm.sendReactionEmojiFromMenu(chatItem, "\u{1F44D}");
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf-messages/reactions", {
+ destination_hash: "test-hash",
+ target_message_hash: "msg-hash",
+ emoji: "\u{1F44D}",
+ });
+ expect(chatItem.lxmf_message.reactions).toHaveLength(1);
+ expect(chatItem.lxmf_message.reactions[0]).toMatchObject({
+ emoji: "\u{1F44D}",
+ sender: "my-hash",
+ reactionHash: "reaction-hash",
+ });
+ wrapper.unmount();
+ });
+
+ it("toasts and does not crash when reaction API fails", async () => {
+ axiosMock.post.mockRejectedValueOnce({ response: { data: { message: "No path" } } });
+ const wrapper = mountViewer();
+ const chatItem = parentChatItem();
+ wrapper.vm.chatItems = [chatItem];
+
+ await wrapper.vm.sendReactionEmojiFromMenu(chatItem, "\u{1F44D}");
+ await flushPromises();
+
+ expect(ToastUtils.error).toHaveBeenCalledWith("messages.reaction_send_failed");
+ expect(chatItem.lxmf_message.reactions).toHaveLength(0);
+ wrapper.unmount();
+ });
+
+ it("ignores null/invalid chatItem and emoji without throwing", async () => {
+ const wrapper = mountViewer();
+ await flushPromises();
+ axiosMock.post.mockClear();
+ await expect(wrapper.vm.sendReactionEmojiFromMenu(null, "\u{1F44D}")).resolves.toBeUndefined();
+ await expect(wrapper.vm.sendReactionEmojiFromMenu({ lxmf_message: {} }, "")).resolves.toBeUndefined();
+ await expect(wrapper.vm.sendReactionEmojiFromMenu(parentChatItem(), null)).resolves.toBeUndefined();
+ const reactionPosts = axiosMock.post.mock.calls.filter(
+ (c) => typeof c[0] === "string" && c[0].includes("/lxmf-messages/reactions")
+ );
+ expect(reactionPosts).toHaveLength(0);
+ wrapper.unmount();
+ });
+
+ it("dedupes concurrent double-taps of the same reaction (Android race)", async () => {
+ let resolvePost;
+ axiosMock.post.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePost = resolve;
+ })
+ );
+ const wrapper = mountViewer();
+ const chatItem = parentChatItem();
+ wrapper.vm.chatItems = [chatItem];
+
+ const p1 = wrapper.vm.sendReactionEmojiFromMenu(chatItem, "\u{1F44D}");
+ const p2 = wrapper.vm.sendReactionEmojiFromMenu(chatItem, "\u{1F44D}");
+ expect(axiosMock.post).toHaveBeenCalledTimes(1);
+
+ resolvePost({
+ data: {
+ lxmf_message: {
+ hash: "reaction-hash",
+ is_reaction: true,
+ reaction_to: "msg-hash",
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "my-hash",
+ source_hash: "my-hash",
+ },
+ },
+ });
+ await Promise.all([p1, p2]);
+ await flushPromises();
+
+ expect(chatItem.lxmf_message.reactions).toHaveLength(1);
+ wrapper.unmount();
+ });
+
+ it("applyIncomingReaction merges case-insensitively and upgrades null reactionHash", async () => {
+ const wrapper = mountViewer();
+ const chatItem = parentChatItem();
+ chatItem.lxmf_message.hash = "AaBbCcDdEeFf00112233445566778899";
+ chatItem.lxmf_message.reactions = [{ emoji: "\u{1F44D}", sender: "My-Hash", reactionHash: null }];
+ wrapper.vm.chatItems = [chatItem];
+
+ wrapper.vm.applyIncomingReaction({
+ hash: "server-reaction",
+ is_reaction: true,
+ reaction_to: "aabbccddeeff00112233445566778899",
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "my-hash",
+ source_hash: "my-hash",
+ });
+
+ expect(chatItem.lxmf_message.reactions).toHaveLength(1);
+ expect(chatItem.lxmf_message.reactions[0].reactionHash).toBe("server-reaction");
+ wrapper.unmount();
+ });
+
+ it("applyIncomingReaction tolerates malformed payloads", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.chatItems = [parentChatItem()];
+ expect(() => wrapper.vm.applyIncomingReaction(null)).not.toThrow();
+ expect(() => wrapper.vm.applyIncomingReaction({})).not.toThrow();
+ expect(() => wrapper.vm.applyIncomingReaction({ reaction_to: "missing", reaction_emoji: "x" })).not.toThrow();
+ expect(() =>
+ wrapper.vm.applyIncomingReaction({
+ reaction_to: "msg-hash",
+ reaction_emoji: 123,
+ source_hash: "peer",
+ })
+ ).not.toThrow();
+ expect(wrapper.vm.chatItems[0].lxmf_message.reactions).toHaveLength(0);
+ wrapper.unmount();
+ });
+
+ it("onLxmfMessageCreated applies outbound reactions instead of inserting a bubble", async () => {
+ const wrapper = mountViewer();
+ const chatItem = parentChatItem();
+ wrapper.vm.chatItems = [chatItem];
+
+ wrapper.vm.onLxmfMessageCreated({
+ hash: "out-reaction",
+ destination_hash: "test-hash",
+ source_hash: "my-hash",
+ is_reaction: true,
+ reaction_to: "msg-hash",
+ reaction_emoji: "\u2764\uFE0F",
+ reaction_sender: "my-hash",
+ });
+
+ expect(wrapper.vm.chatItems).toHaveLength(1);
+ expect(chatItem.lxmf_message.reactions).toHaveLength(1);
+ expect(chatItem.lxmf_message.reactions[0].emoji).toBe("\u2764\uFE0F");
+ wrapper.unmount();
+ });
+
+ it("does not crash when Android touchcancel fires after picker close mid-drag", async () => {
+ const wrapper = mountViewer();
+ const chatItem = parentChatItem();
+ wrapper.vm.chatItems = [chatItem];
+ wrapper.vm.openReactionPicker(chatItem);
+ await wrapper.vm.$nextTick();
+
+ const panel = document.createElement("div");
+ panel.getBoundingClientRect = () => ({
+ left: 10,
+ top: 20,
+ width: 200,
+ height: 300,
+ right: 210,
+ bottom: 320,
+ });
+ wrapper.vm.$refs.reactionPickerPanel = panel;
+
+ wrapper.vm.onReactionPickerDragStart({
+ touches: [{ clientX: 15, clientY: 25 }],
+ preventDefault: vi.fn(),
+ });
+ expect(wrapper.vm.reactionDragState).not.toBeNull();
+
+ // Closing the picker (or Android touchcancel) must remove listeners safely.
+ wrapper.vm.closeReactionPicker();
+ expect(wrapper.vm.reactionDragState).toBeNull();
+
+ expect(() => {
+ document.dispatchEvent(new Event("touchmove"));
+ document.dispatchEvent(new Event("touchcancel"));
+ document.dispatchEvent(new Event("touchend"));
+ document.dispatchEvent(new Event("mousemove"));
+ }).not.toThrow();
+
+ wrapper.unmount();
+ });
+
+ it("cleans up reaction drag listeners on unmount", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.openReactionPicker(parentChatItem());
+ await wrapper.vm.$nextTick();
+ const panel = document.createElement("div");
+ panel.getBoundingClientRect = () => ({
+ left: 0,
+ top: 0,
+ width: 100,
+ height: 100,
+ right: 100,
+ bottom: 100,
+ });
+ wrapper.vm.$refs.reactionPickerPanel = panel;
+ wrapper.vm.onReactionPickerDragStart({ clientX: 1, clientY: 2 });
+ expect(typeof wrapper.vm._reactionDragCleanup).toBe("function");
+ wrapper.unmount();
+ expect(() => {
+ document.dispatchEvent(new Event("touchmove"));
+ document.dispatchEvent(new Event("touchend"));
+ }).not.toThrow();
+ });
+
+ it("reactionReactorLabel never throws on bad sender values", async () => {
+ const wrapper = mountViewer();
+ expect(wrapper.vm.reactionReactorLabel(null)).toBe("");
+ expect(wrapper.vm.reactionReactorLabel(undefined)).toBe("");
+ expect(wrapper.vm.reactionReactorLabel(12)).toContain("<");
+ expect(wrapper.vm.reactionReactorLabel("my-hash")).toBe("messages.reaction_you");
+ wrapper.unmount();
+ });
+
+ it("emoji click closes picker and sends without throwing when detail is missing", async () => {
+ const wrapper = mountViewer();
+ wrapper.vm.openReactionPicker(parentChatItem());
+ expect(() => wrapper.vm.onReactionPickerEmojiClick({})).not.toThrow();
+ expect(() => wrapper.vm.onReactionPickerEmojiClick({ detail: {} })).not.toThrow();
+ // Malformed picker events leave the picker open so the user can try again.
+ expect(wrapper.vm.reactionPickerChatItem).not.toBeNull();
+ expect(() => wrapper.vm.onReactionPickerEmojiClick({ detail: { unicode: "\u{1F44D}" } })).not.toThrow();
+ expect(wrapper.vm.reactionPickerChatItem).toBeNull();
+ await flushPromises();
+ wrapper.unmount();
+ });
+});
diff --git a/tests/frontend/ForwarderPage.test.js b/tests/frontend/ForwarderPage.test.js
index 05cdf608..f981b8ad 100644
--- a/tests/frontend/ForwarderPage.test.js
+++ b/tests/frontend/ForwarderPage.test.js
@@ -7,13 +7,22 @@ vi.mock("@/js/WebSocketConnection", () => ({
default: {
on: vi.fn(),
off: vi.fn(),
- send: vi.fn(),
+ send: vi.fn(() => true),
+ },
+}));
+
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
},
}));
describe("ForwarderPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
+ WebSocketConnection.send.mockReturnValue(true);
});
const mountForwarderPage = () => {
diff --git a/tests/frontend/IdentitiesPage.test.js b/tests/frontend/IdentitiesPage.test.js
index a53f2669..f24af13f 100644
--- a/tests/frontend/IdentitiesPage.test.js
+++ b/tests/frontend/IdentitiesPage.test.js
@@ -221,6 +221,65 @@ describe("IdentitiesPage.vue", () => {
}
});
+ it("restores identity from base32 with whitespace normalization and offers switch", async () => {
+ const ToastUtils = (await import("@/js/ToastUtils")).default;
+ const DialogUtils = (await import("@/js/DialogUtils")).default;
+ DialogUtils.confirm.mockResolvedValue(false);
+ axiosMock.post.mockImplementation((url, body) => {
+ if (url === "/api/v1/identity/restore") {
+ expect(body).toEqual({ base32: "ABCD1234" });
+ return Promise.resolve({
+ data: {
+ message: "Identity restored. Restart app to use the new identity.",
+ identity: { hash: "restored_hash", display_name: "Restored" },
+ },
+ });
+ }
+ return Promise.resolve({ data: { hotswapped: true } });
+ });
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.showImportModal = true;
+ wrapper.vm.identityRestoreBase32 = "AB CD\n1234";
+ await wrapper.vm.restoreIdentityBase32();
+
+ expect(ToastUtils.success).toHaveBeenCalled();
+ expect(DialogUtils.confirm).toHaveBeenCalled();
+ expect(wrapper.vm.showImportModal).toBe(false);
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/identities");
+ });
+
+ it("rejects empty identity restore files with toast", async () => {
+ const ToastUtils = (await import("@/js/ToastUtils")).default;
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.showImportModal = true;
+ const empty = new File([], "identity.bin", { type: "application/octet-stream" });
+ wrapper.vm.onIdentityRestoreFileChange({ target: { files: [empty], value: "x" } });
+ expect(wrapper.vm.identityRestoreFile).toBeNull();
+ expect(ToastUtils.error).toHaveBeenCalledWith("identities.identity_restore_empty_file");
+ expect(wrapper.vm.showImportModal).toBe(true);
+ });
+
+ it("keeps import modal open and surfaces API error on file restore failure", async () => {
+ const ToastUtils = (await import("@/js/ToastUtils")).default;
+ axiosMock.post.mockRejectedValue({
+ response: { data: { message: "Identity file is empty" } },
+ });
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.showImportModal = true;
+ wrapper.vm.identityRestoreFile = new File([new Uint8Array([1, 2, 3])], "identity.bin");
+ await wrapper.vm.restoreIdentityFile();
+ expect(wrapper.vm.identityRestoreError).toBe("Identity file is empty");
+ expect(ToastUtils.error).toHaveBeenCalledWith("Identity file is empty");
+ expect(wrapper.vm.showImportModal).toBe(true);
+ });
+
it("performance: measures identity list rendering for many identities", async () => {
const numIdentities = 500;
const identities = Array.from({ length: numIdentities }, (_, i) => ({
diff --git a/tests/frontend/MapRemoteOverlayPanel.test.js b/tests/frontend/MapRemoteOverlayPanel.test.js
new file mode 100644
index 00000000..cf735d24
--- /dev/null
+++ b/tests/frontend/MapRemoteOverlayPanel.test.js
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { nextTick } from "vue";
+import MapRemoteOverlayPanel from "../../meshchatx/src/frontend/components/map/internal/MapRemoteOverlayPanel.vue";
+
+describe("MapRemoteOverlayPanel", () => {
+ beforeEach(() => {
+ window.api = {
+ get: vi.fn(async (url) => {
+ if (url === "/api/v1/map/overlays") {
+ return { overlays: [] };
+ }
+ return {};
+ }),
+ post: vi.fn(async () => ({
+ job_id: "j1",
+ overlays: [{ id: 1, name: "a", status: "fetching", visible: 1 }],
+ })),
+ patch: vi.fn(async () => ({})),
+ delete: vi.fn(async () => ({})),
+ };
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete window.api;
+ });
+
+ it("loads overlays on mount", async () => {
+ const wrapper = mount(MapRemoteOverlayPanel, {
+ global: {
+ mocks: {
+ $t: (k) => k,
+ },
+ },
+ });
+ await flushPromises();
+ expect(window.api.get).toHaveBeenCalledWith("/api/v1/map/overlays");
+ wrapper.unmount();
+ });
+
+ it("posts nomadnet import payload", async () => {
+ const wrapper = mount(MapRemoteOverlayPanel, {
+ global: {
+ mocks: {
+ $t: (k) => k,
+ },
+ },
+ });
+ await flushPromises();
+ await wrapper.setData({
+ kind: "nomadnet_file",
+ url: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:/file/a.geojson",
+ });
+ await wrapper.vm.importSources();
+ expect(window.api.post).toHaveBeenCalledWith(
+ "/api/v1/map/overlays",
+ expect.objectContaining({
+ kind: "nomadnet_file",
+ url: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:/file/a.geojson",
+ })
+ );
+ wrapper.unmount();
+ });
+
+ it("ignores stale job poll after newer generation", async () => {
+ vi.useFakeTimers();
+ let jobCalls = 0;
+ window.api.get = vi.fn(async (url) => {
+ if (url === "/api/v1/map/overlays") {
+ return { overlays: [] };
+ }
+ if (url.includes("/jobs/")) {
+ jobCalls += 1;
+ return { status: "running", phase: "transferring" };
+ }
+ return {};
+ });
+ const wrapper = mount(MapRemoteOverlayPanel, {
+ global: { mocks: { $t: (k) => k } },
+ });
+ await flushPromises();
+ wrapper.vm.watchJob("old");
+ const genAfterFirst = wrapper.vm.jobGeneration;
+ wrapper.vm.watchJob("new");
+ expect(wrapper.vm.jobGeneration).toBeGreaterThan(genAfterFirst);
+ await vi.advanceTimersByTimeAsync(1500);
+ await flushPromises();
+ // Only the latest job id should keep polling meaningfully
+ const jobUrls = window.api.get.mock.calls.map((c) => c[0]).filter((u) => String(u).includes("/jobs/"));
+ expect(jobUrls.some((u) => String(u).includes("new"))).toBe(true);
+ wrapper.unmount();
+ });
+});
diff --git a/tests/frontend/MessageReactionsOverlay.test.js b/tests/frontend/MessageReactionsOverlay.test.js
new file mode 100644
index 00000000..2069040c
--- /dev/null
+++ b/tests/frontend/MessageReactionsOverlay.test.js
@@ -0,0 +1,81 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import MessageReactionsOverlay from "@/components/messages/MessageReactionsOverlay.vue";
+
+describe("MessageReactionsOverlay", () => {
+ const mountOverlay = (props = {}) =>
+ mount(MessageReactionsOverlay, {
+ props: {
+ chatItem: { lxmf_message: { hash: "m1" } },
+ cv: {
+ openReactionPicker: vi.fn(),
+ reactionReactorLabel: vi.fn(() => "You"),
+ },
+ reactions: [],
+ showReactButton: true,
+ ...props,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ stubs: { MaterialDesignIcon: true },
+ },
+ });
+
+ it("renders reaction chips and react button", () => {
+ const wrapper = mountOverlay({
+ reactions: [
+ { emoji: "\u{1F44D}", sender: "a", reactionHash: "r1" },
+ { emoji: "\u2764\uFE0F", sender: "b", reactionHash: "r2" },
+ ],
+ });
+ expect(wrapper.text()).toContain("\u{1F44D}");
+ expect(wrapper.text()).toContain("\u2764\uFE0F");
+ expect(wrapper.find("button").exists()).toBe(true);
+ });
+
+ it("caps visible reactions and shows +N overflow", () => {
+ const reactions = Array.from({ length: 7 }, (_, i) => ({
+ emoji: "\u{1F44D}",
+ sender: `s${i}`,
+ reactionHash: `r${i}`,
+ }));
+ const wrapper = mountOverlay({ reactions });
+ expect(wrapper.text()).toContain("+3");
+ });
+
+ it("tolerates null/undefined/non-array reactions without crashing", () => {
+ expect(() => mountOverlay({ reactions: null })).not.toThrow();
+ expect(() => mountOverlay({ reactions: undefined })).not.toThrow();
+ expect(() => mountOverlay({ reactions: "bad" })).not.toThrow();
+ expect(() =>
+ mountOverlay({
+ reactions: [null, undefined, { emoji: "x", sender: "a", reactionHash: "r" }],
+ })
+ ).not.toThrow();
+ });
+
+ it("does not crash when reactionReactorLabel throws", () => {
+ const wrapper = mountOverlay({
+ reactions: [{ emoji: "\u{1F44D}", sender: "a", reactionHash: "r1" }],
+ cv: {
+ openReactionPicker: vi.fn(),
+ reactionReactorLabel: () => {
+ throw new Error("label boom");
+ },
+ },
+ });
+ expect(wrapper.exists()).toBe(true);
+ expect(wrapper.text()).toContain("\u{1F44D}");
+ });
+
+ it("opens picker via react button", async () => {
+ const openReactionPicker = vi.fn();
+ const chatItem = { lxmf_message: { hash: "m1" } };
+ const wrapper = mountOverlay({
+ chatItem,
+ cv: { openReactionPicker, reactionReactorLabel: () => "" },
+ });
+ await wrapper.find("button").trigger("click");
+ expect(openReactionPicker).toHaveBeenCalledWith(chatItem);
+ });
+});
diff --git a/tests/frontend/MessageSendingFailures.test.js b/tests/frontend/MessageSendingFailures.test.js
index b5d66ad7..d89b5783 100644
--- a/tests/frontend/MessageSendingFailures.test.js
+++ b/tests/frontend/MessageSendingFailures.test.js
@@ -4,6 +4,7 @@ import ConversationViewer from "@/components/messages/ConversationViewer.vue";
import WebSocketConnection from "@/js/WebSocketConnection";
import GlobalState from "@/js/GlobalState";
import DialogUtils from "@/js/DialogUtils";
+import ToastUtils from "@/js/ToastUtils";
describe("MessageSendingFailures.test.js", () => {
let axiosMock;
@@ -21,7 +22,12 @@ describe("MessageSendingFailures.test.js", () => {
return Promise.resolve({ data: { lxmf_messages: [] } });
return Promise.resolve({ data: {} });
}),
- post: vi.fn().mockImplementation(() => Promise.resolve({ data: { lxmf_message: { hash: "mock" } } })),
+ post: vi.fn().mockImplementation((url) => {
+ if (typeof url === "string" && (url.includes("/request-path") || url.includes("/drop-path"))) {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: { lxmf_message: { hash: "mock" } } });
+ }),
delete: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
@@ -32,6 +38,7 @@ describe("MessageSendingFailures.test.js", () => {
vi.spyOn(DialogUtils, "confirm").mockResolvedValue(true);
vi.spyOn(DialogUtils, "alert").mockImplementation(() => {});
+ vi.spyOn(ToastUtils, "error").mockImplementation(() => {});
});
afterEach(() => {
@@ -107,9 +114,18 @@ describe("MessageSendingFailures.test.js", () => {
});
const wrapper = mountConversationViewer();
+ wrapper.vm.peerPathSnapshot = {
+ path: { hops: 1 },
+ path_stale: false,
+ path_unresponsive: false,
+ };
wrapper.vm.newMessageText = "http LAN host";
await wrapper.vm.sendMessage();
+ await vi.waitFor(() => {
+ const sendCalls = axiosMock.post.mock.calls.filter((c) => c[0] === "/api/v1/lxmf-messages/send");
+ expect(sendCalls.length).toBeGreaterThan(0);
+ });
expect(DialogUtils.alert).not.toHaveBeenCalled();
expect(axiosMock.post).toHaveBeenCalledWith(
@@ -124,6 +140,46 @@ describe("MessageSendingFailures.test.js", () => {
expect(wrapper.vm.chatItems.some((item) => item.lxmf_message.hash === "mock")).toBe(true);
});
+ it("warms path before direct send when peer path is missing", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.peerPathSnapshot = { path: null, path_stale: true, path_unresponsive: false };
+ wrapper.vm.newMessageText = "need path";
+ wrapper.vm.newMessageDeliveryMethod = "direct";
+
+ await wrapper.vm.sendMessage();
+ await vi.waitFor(() => {
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/destination/test-hash/request-path");
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/lxmf-messages/send",
+ expect.objectContaining({
+ delivery_method: "direct",
+ })
+ );
+ });
+ });
+
+ it("skips path warm for propagated delivery", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.peerPathSnapshot = { path: null, path_stale: true, path_unresponsive: false };
+ wrapper.vm.newMessageText = "via prop";
+ wrapper.vm.newMessageDeliveryMethod = "propagated";
+
+ await wrapper.vm.sendMessage();
+ await vi.waitFor(() => {
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/lxmf-messages/send",
+ expect.objectContaining({
+ delivery_method: "propagated",
+ })
+ );
+ });
+
+ const pathWarmCalls = axiosMock.post.mock.calls.filter(
+ (c) => typeof c[0] === "string" && c[0].includes("/request-path")
+ );
+ expect(pathWarmCalls).toHaveLength(0);
+ });
+
it("updates UI when message state becomes failed via WebSocket", async () => {
const wrapper = mountConversationViewer();
const messageHash = "msg-123";
@@ -160,6 +216,11 @@ describe("MessageSendingFailures.test.js", () => {
it("handles second image failure in multi-image send", async () => {
const wrapper = mountConversationViewer();
+ wrapper.vm.peerPathSnapshot = {
+ path: { hops: 1 },
+ path_stale: false,
+ path_unresponsive: false,
+ };
wrapper.vm.newMessageText = "Two images";
const image1 = new File([""], "image1.png", { type: "image/png" });
@@ -170,21 +231,32 @@ describe("MessageSendingFailures.test.js", () => {
await wrapper.vm.onImageSelected(image1);
await wrapper.vm.onImageSelected(image2);
- // First image succeeds, second fails
- axiosMock.post
- .mockResolvedValueOnce({
- data: { lxmf_message: { hash: "hash-1", content: "Two images", state: "outbound" } },
- })
- .mockRejectedValueOnce({ response: { data: { message: "Second image failed" } } });
+ let sendCount = 0;
+ axiosMock.post.mockImplementation((url) => {
+ if (typeof url === "string" && (url.includes("/request-path") || url.includes("/drop-path"))) {
+ return Promise.resolve({ data: {} });
+ }
+ if (typeof url === "string" && url.includes("/lxmf-messages/send")) {
+ sendCount += 1;
+ if (sendCount === 1) {
+ return Promise.resolve({
+ data: { lxmf_message: { hash: "hash-1", content: "Two images", state: "outbound" } },
+ });
+ }
+ return Promise.reject({ response: { data: { message: "Second image failed" } } });
+ }
+ return Promise.resolve({ data: {} });
+ });
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await wrapper.vm.sendMessage();
+ await vi.waitFor(() => {
+ expect(sendCount).toBe(2);
+ });
- // Both images should be processed, but second one logs an error
- const sendCalls = axiosMock.post.mock.calls.filter((c) => c[0] === "/api/v1/lxmf-messages/send");
- expect(sendCalls.length).toBe(2);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to send image 2"), expect.anything());
+ expect(ToastUtils.error).toHaveBeenCalled();
consoleSpy.mockRestore();
});
diff --git a/tests/frontend/MobilePopoutVisibility.test.js b/tests/frontend/MobilePopoutVisibility.test.js
new file mode 100644
index 00000000..3c4789f2
--- /dev/null
+++ b/tests/frontend/MobilePopoutVisibility.test.js
@@ -0,0 +1,157 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("vuetify/components/VTooltip", () => ({
+ VTooltip: {
+ name: "VTooltip",
+ template: '<div class="v-tooltip-stub"><slot /></div>',
+ },
+}));
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ send: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+}));
+
+import RelayChatPage from "@/components/relay/RelayChatPage.vue";
+import NomadNetworkPage from "@/components/nomadnetwork/NomadNetworkPage.vue";
+import { mountToolsPageGlobals } from "./testI18n.js";
+
+const HUB_HASH = "00112233445566778899aabbccddeeff";
+
+function makeHub(overrides = {}) {
+ return {
+ hub_hash: HUB_HASH,
+ name: "Test Hub",
+ display_name: "Test Hub",
+ rooms: ["lobby"],
+ available_rooms: [],
+ motd: null,
+ max_msg_body_bytes: 350,
+ ...overrides,
+ };
+}
+
+describe("mobile popout visibility", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ }))
+ );
+
+ window.api = {
+ get: vi.fn().mockImplementation((url) => {
+ if (url === "/api/v1/rrc/hubs") {
+ return Promise.resolve({ data: { hubs: [makeHub()] } });
+ }
+ if (url.includes("/messages")) {
+ return Promise.resolve({ data: { messages: [], has_more: false } });
+ }
+ if (url.includes("/members")) {
+ return Promise.resolve({ data: { members: [] } });
+ }
+ if (url === "/api/v1/favourites") {
+ return Promise.resolve({ data: { favourites: [] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ });
+
+ afterEach(() => {
+ delete window.api;
+ vi.unstubAllGlobals();
+ });
+
+ it("hides relay channel popout on mobile viewport", async () => {
+ const wrapper = mount(RelayChatPage, { global: mountToolsPageGlobals() });
+ wrapper.vm.hubs = [makeHub()];
+ wrapper.vm.smUp = false;
+ await wrapper.vm.selectRoom(HUB_HASH, "lobby");
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="relay-popout"]').exists()).toBe(false);
+
+ wrapper.vm.smUp = true;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('[data-testid="relay-popout"]').exists()).toBe(true);
+
+ wrapper.unmount();
+ });
+
+ it("omits nomad popout from mobile overflow menu", async () => {
+ const wrapper = mount(NomadNetworkPage, {
+ props: { destinationHash: "" },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {}, name: "nomadnetwork", meta: {}, params: {} },
+ $router: { replace: vi.fn(), push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: {
+ template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
+ props: ["iconName"],
+ },
+ IconButton: {
+ template: '<button type="button" v-bind="$attrs"><slot /></button>',
+ },
+ DropDownMenu: {
+ template:
+ '<div class="dd-stub"><slot name="button" /><div class="dd-items"><slot name="items" /></div></div>',
+ },
+ DropDownMenuItem: {
+ template: '<div class="dd-item"><slot /></div>',
+ },
+ NomadNetworkSidebar: true,
+ LoadingSpinner: true,
+ NomadBrowserContextMenu: true,
+ VTooltip: true,
+ LxmfUserIcon: true,
+ },
+ directives: {
+ "click-outside": { mounted() {}, unmounted() {} },
+ },
+ },
+ });
+
+ wrapper.vm.selectedNode = {
+ destination_hash: "b".repeat(32),
+ display_name: "Node",
+ };
+ await wrapper.vm.$nextTick();
+
+ const mobileMenu = wrapper.findAll(".dd-stub").find((node) => {
+ let el = node.element;
+ while (el) {
+ if (el.classList?.contains("lg:hidden")) {
+ return true;
+ }
+ el = el.parentElement;
+ }
+ return false;
+ });
+
+ expect(mobileMenu).toBeTruthy();
+ const items = mobileMenu.find(".dd-items");
+ expect(items.text()).not.toContain("nomadnet.pop_out_browser");
+ expect(items.find('[data-icon-name="open-in-new"]').exists()).toBe(false);
+
+ wrapper.unmount();
+ });
+});
diff --git a/tests/frontend/NotificationBellConversationSync.test.js b/tests/frontend/NotificationBellConversationSync.test.js
index 7a66fd8a..1d314017 100644
--- a/tests/frontend/NotificationBellConversationSync.test.js
+++ b/tests/frontend/NotificationBellConversationSync.test.js
@@ -264,7 +264,7 @@ describe("NotificationBell conversation read sync", () => {
await flushPromises();
expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
- expect(conversation.is_unread).toBe(false);
+ expect(conversation.is_unread).toBe(true);
viewer.unmount();
});
diff --git a/tests/frontend/RNSHManagerPage.test.js b/tests/frontend/RNSHManagerPage.test.js
index 8ad5b155..740ceedf 100644
--- a/tests/frontend/RNSHManagerPage.test.js
+++ b/tests/frontend/RNSHManagerPage.test.js
@@ -167,7 +167,7 @@ describe("RNSHManagerPage.vue", () => {
makeSession({
output_chunks: [{ seq: 99, text: "LINE_TAIL\n", ts: 2 }],
output_text: "LINE_0400\nLINE_TAIL\n",
- }),
+ })
);
expect(wrapper.vm.outputsBySession[SESSION_ID]).toBe(live);
@@ -182,7 +182,7 @@ describe("RNSHManagerPage.vue", () => {
makeSession({
output_chunks: [{ seq: 1, text: "tail only\n", ts: 1 }],
output_text: longText,
- }),
+ })
);
expect(wrapper.vm.outputsBySession[SESSION_ID]).toBe(longText);
diff --git a/tests/frontend/RNStatusPage.test.js b/tests/frontend/RNStatusPage.test.js
index cc411ed9..583bbc88 100644
--- a/tests/frontend/RNStatusPage.test.js
+++ b/tests/frontend/RNStatusPage.test.js
@@ -58,9 +58,25 @@ describe("RNStatusPage.vue", () => {
expect(wrapper.text()).toContain("Discovered");
expect(wrapper.text()).toContain("Active Links: 5");
expect(wrapper.text()).toContain("Blackhole: Publishing");
+ expect(wrapper.vm.blackholeEnabled).toBe(true);
expect(wrapper.text()).toContain("src1");
});
+ it("labels disabled blackhole as Inactive", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ interfaces: [],
+ link_count: 0,
+ blackhole_enabled: false,
+ blackhole_count: 0,
+ blackhole_sources: [],
+ },
+ });
+ const wrapper = mountRNStatusPage();
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+ expect(wrapper.text()).toContain("Blackhole: Inactive");
+ });
+
it("refreshes status when button is clicked", async () => {
const wrapper = mountRNStatusPage();
await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
diff --git a/tests/frontend/TutorialModalMigration.test.js b/tests/frontend/TutorialModalMigration.test.js
index cdcf133d..56887cc2 100644
--- a/tests/frontend/TutorialModalMigration.test.js
+++ b/tests/frontend/TutorialModalMigration.test.js
@@ -20,6 +20,13 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn().mockResolvedValue(true),
},
}));
@@ -745,4 +752,191 @@ describe("TutorialModal getting started migration", () => {
expect(ToastUtils.error).toHaveBeenCalledWith("switch failed");
wrapper.unmount();
});
+
+ it("identity file import posts multipart and clears stale hash on re-pick", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/identity/restore") {
+ return Promise.resolve({
+ data: { identity: { hash: "file_hash" }, message: "ok" },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "import";
+ wrapper.vm.identityName = "File User";
+ wrapper.vm.identityImportedHash = "stale_hash";
+ const file = new File([new Uint8Array([1, 2, 3, 4])], "identity.bin", {
+ type: "application/octet-stream",
+ });
+ wrapper.vm.onIdentityImportFileChange({ target: { files: [file], value: "x" } });
+ expect(wrapper.vm.identityImportedHash).toBeNull();
+ expect(wrapper.vm.identityImportFile).toBe(file);
+
+ await wrapper.vm.handlePrimaryAction();
+ expect(wrapper.vm.identityImportedHash).toBe("file_hash");
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ "/api/v1/identity/restore",
+ expect.any(FormData),
+ expect.objectContaining({
+ headers: { "Content-Type": "multipart/form-data" },
+ })
+ );
+ wrapper.unmount();
+ });
+
+ it("rejects empty identity import files", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+
+ const empty = new File([], "identity.bin", { type: "application/octet-stream" });
+ wrapper.vm.onIdentityImportFileChange({ target: { files: [empty], value: "x" } });
+ expect(wrapper.vm.identityImportFile).toBeNull();
+ expect(wrapper.vm.identityImportError).toBe(en.tutorial.identity_import_empty_file);
+ wrapper.unmount();
+ });
+
+ it("normalizes whitespace in base32 before restore", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url, body) => {
+ if (url === "/api/v1/identity/restore") {
+ expect(body.base32).toBe("ABCD1234");
+ return Promise.resolve({
+ data: { identity: { hash: "ws_hash" }, message: "ok" },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "import";
+ wrapper.vm.identityImportBase32 = "AB CD\n1234";
+ await wrapper.vm.handlePrimaryAction();
+ expect(wrapper.vm.identityImportedHash).toBe("ws_hash");
+ wrapper.unmount();
+ });
+
+ it("finishTutorial warns but continues when default identity delete fails", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/identities/switch") {
+ return Promise.resolve({ data: { hotswapped: true } });
+ }
+ if (url === "/api/v1/app/tutorial/seen") {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ axiosMock.delete = vi.fn().mockRejectedValue({ response: { data: { message: "delete failed" } } });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.visible = true;
+ wrapper.vm.currentStep = wrapper.vm.totalSteps;
+ wrapper.vm.identityImportedHash = "imported_hash";
+ wrapper.vm.originalIdentityHash = "default_identity";
+ await wrapper.vm.finishTutorial();
+ await flushPromises();
+
+ expect(wrapper.vm.visible).toBe(false);
+ expect(ToastUtils.warning).toHaveBeenCalledWith(en.tutorial.identity_default_delete_failed);
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/app/tutorial/seen");
+ wrapper.unmount();
+ });
+
+ it("finishTutorial is race-safe against double clicks", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ let switchCalls = 0;
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/identities/switch") {
+ switchCalls += 1;
+ return new Promise((resolve) => {
+ setTimeout(() => resolve({ data: { hotswapped: true } }), 20);
+ });
+ }
+ if (url === "/api/v1/app/tutorial/seen") {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ axiosMock.delete = vi.fn().mockResolvedValue({ data: {} });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.visible = true;
+ wrapper.vm.currentStep = wrapper.vm.totalSteps;
+ wrapper.vm.identityImportedHash = "imported_hash";
+ wrapper.vm.originalIdentityHash = "default_identity";
+
+ const first = wrapper.vm.finishTutorial();
+ const second = wrapper.vm.finishTutorial();
+ await Promise.all([first, second]);
+ await flushPromises();
+
+ expect(switchCalls).toBe(1);
+ wrapper.unmount();
+ });
});
diff --git a/tests/frontend/Utils.test.js b/tests/frontend/Utils.test.js
index 1a75ec38..d73d6f13 100644
--- a/tests/frontend/Utils.test.js
+++ b/tests/frontend/Utils.test.js
@@ -8,6 +8,13 @@ describe("Utils.js", () => {
const hash = "e253d0b19fe34c3f0a09569165abc45a";
expect(Utils.formatDestinationHash(hash)).toBe("<e253d0b1...65abc45a>");
});
+
+ it("tolerates null and empty values without throwing", () => {
+ expect(Utils.formatDestinationHash(null)).toBe("<>");
+ expect(Utils.formatDestinationHash(undefined)).toBe("<>");
+ expect(Utils.formatDestinationHash("")).toBe("<>");
+ expect(Utils.formatDestinationHash(12)).toBe("<12...12>");
+ });
});
describe("formatBytes", () => {
diff --git a/tests/frontend/WebSocketConnection.test.js b/tests/frontend/WebSocketConnection.test.js
index 0f40477c..034971d8 100644
--- a/tests/frontend/WebSocketConnection.test.js
+++ b/tests/frontend/WebSocketConnection.test.js
@@ -338,7 +338,7 @@ describe("WebSocketConnection module", () => {
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
expect(WebSocketConnection.ws).toBeNull();
- expect(() => WebSocketConnection.send("hello")).not.toThrow();
+ expect(WebSocketConnection.send("hello")).toBe(false);
expect(() => WebSocketConnection.ping()).not.toThrow();
});
diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js
index 6309c400..509b7849 100644
--- a/tests/frontend/fixtures/settingsPageTestApi.js
+++ b/tests/frontend/fixtures/settingsPageTestApi.js
@@ -66,6 +66,16 @@ export function buildFullServerConfig(overrides = {}) {
map_default_zoom: 2,
map_tile_server_url: "",
map_nominatim_api_url: "",
+ map_overlay_max_bytes: 8 * 1024 * 1024,
+ map_overlay_max_features: 50000,
+ map_overlay_max_kmz_uncompressed_bytes: 16 * 1024 * 1024,
+ map_overlay_max_sources: 64,
+ map_overlay_max_concurrent_jobs: 2,
+ map_overlay_path_timeout_seconds: 30,
+ map_overlay_transfer_timeout_seconds: 120,
+ map_overlay_job_timeout_seconds: 300,
+ map_overlay_max_retries: 3,
+ map_overlay_retry_delay_seconds: 2,
do_not_disturb_enabled: false,
telephone_allow_calls_from_contacts_only: false,
telephone_audio_profile_id: null,
diff --git a/tests/frontend/lxmfReactions.test.js b/tests/frontend/lxmfReactions.test.js
index cc14c697..c36118de 100644
--- a/tests/frontend/lxmfReactions.test.js
+++ b/tests/frontend/lxmfReactions.test.js
@@ -145,4 +145,44 @@ describe("mergeLxmfReactionRowsIntoMessages", () => {
const out = mergeLxmfReactionRowsIntoMessages(incoming);
expect(out[0].reactions.length).toBe(200);
});
+
+ it("skips null rows and empty emoji without throwing", () => {
+ const parentHash = "a".repeat(32);
+ const out = mergeLxmfReactionRowsIntoMessages([
+ null,
+ undefined,
+ { hash: parentHash, content: "x", is_reaction: false },
+ {
+ hash: "r1",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: "",
+ reaction_sender: "e".repeat(32),
+ },
+ {
+ hash: "r2",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: null,
+ reaction_sender: "f".repeat(32),
+ },
+ {
+ hash: "r3",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "Aa".repeat(16),
+ },
+ {
+ hash: "r4",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "aa".repeat(16),
+ },
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].reactions).toHaveLength(1);
+ expect(out[0].reactions[0].emoji).toBe("\u{1F44D}");
+ });
});
diff --git a/tests/frontend/networkStartupWait.test.js b/tests/frontend/networkStartupWait.test.js
index ae73df3d..b56e3430 100644
--- a/tests/frontend/networkStartupWait.test.js
+++ b/tests/frontend/networkStartupWait.test.js
@@ -32,6 +32,22 @@ describe("networkStartupWait", () => {
});
});
+ it("interpretStartupStatus marks degraded when ui_ready", () => {
+ expect(
+ interpretStartupStatus({
+ status: "failed",
+ error: "RNS died",
+ stage: "failed",
+ ui_ready: true,
+ network_degraded: true,
+ })
+ ).toEqual({
+ kind: "degraded",
+ stage: "failed",
+ error: "RNS died",
+ });
+ });
+
it("interpretStartupStatus maps starting stages to labels", () => {
for (const stage of Object.keys(STARTUP_STAGE_LABELS)) {
if (stage === "ready" || stage === "failed") {
@@ -71,7 +87,7 @@ describe("networkStartupWait", () => {
timeoutMs: 5000,
onLine: (text) => lines.push(text),
});
- expect(ready).toBe(true);
+ expect(ready).toBe("ready");
expect(lines).toContain(STARTUP_STAGE_LABELS.rns);
expect(fetchImpl).toHaveBeenCalled();
});
@@ -92,6 +108,27 @@ describe("networkStartupWait", () => {
expect(errors).toEqual(["error"]);
});
+ it("waitForNetworkReady returns degraded when ui_ready", async () => {
+ const degraded = [];
+ const ready = await waitForNetworkReady({
+ fetchImpl: async () => ({
+ ok: true,
+ json: async () => ({
+ status: "failed",
+ error: "I2P brick",
+ ui_ready: true,
+ network_degraded: true,
+ }),
+ }),
+ sleep: async () => {},
+ timeoutMs: 1000,
+ onLine: () => {},
+ onDegraded: (error) => degraded.push(error),
+ });
+ expect(ready).toBe("degraded");
+ expect(degraded).toEqual(["I2P brick"]);
+ });
+
it("waitForNetworkReady keeps polling through fetch errors", async () => {
let calls = 0;
const lines = [];
@@ -110,7 +147,7 @@ describe("networkStartupWait", () => {
timeoutMs: 5000,
onLine: (text) => lines.push(text),
});
- expect(ready).toBe(true);
+ expect(ready).toBe("ready");
expect(lines).toContain("Still starting…");
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────